diff --git a/src/Database/Adapter/MariaDB.php b/src/Database/Adapter/MariaDB.php index ab83167ac7..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; } @@ -1966,9 +1958,11 @@ protected function getOperatorSQL(string $column, Operator $operator, array &$bi $bindKey = $this->registerOperatorBind($binds, $values[0] ?? 1); if (isset($values[1])) { $maxKey = $this->registerOperatorBind($binds, $values[1]); + // Compare with the operand moved across (`col > max - val`) instead of + // `col + val > max`, so the guard never overflows BIGINT when col is near the + // integer range limit. Inclusive: a result landing exactly on max still applies. return "{$quotedColumn} = CASE - WHEN COALESCE({$quotedColumn}, 0) >= :$maxKey THEN :$maxKey - WHEN COALESCE({$quotedColumn}, 0) > :$maxKey - :$bindKey THEN :$maxKey + WHEN COALESCE({$quotedColumn}, 0) > :$maxKey - :$bindKey THEN COALESCE({$quotedColumn}, 0) ELSE COALESCE({$quotedColumn}, 0) + :$bindKey END"; } @@ -1978,9 +1972,10 @@ protected function getOperatorSQL(string $column, Operator $operator, array &$bi $bindKey = $this->registerOperatorBind($binds, $values[0] ?? 1); if (isset($values[1])) { $minKey = $this->registerOperatorBind($binds, $values[1]); + // `col < min + val` rather than `col - val < min`: overflow-safe near the + // integer range limit. Inclusive: a result landing exactly on min still applies. return "{$quotedColumn} = CASE - WHEN COALESCE({$quotedColumn}, 0) <= :$minKey THEN :$minKey - WHEN COALESCE({$quotedColumn}, 0) < :$minKey + :$bindKey THEN :$minKey + WHEN COALESCE({$quotedColumn}, 0) < :$minKey + :$bindKey THEN COALESCE({$quotedColumn}, 0) ELSE COALESCE({$quotedColumn}, 0) - :$bindKey END"; } @@ -1990,10 +1985,12 @@ protected function getOperatorSQL(string $column, Operator $operator, array &$bi $bindKey = $this->registerOperatorBind($binds, $values[0] ?? 1); if (isset($values[1])) { $maxKey = $this->registerOperatorBind($binds, $values[1]); + // Compare via division (`col > max/val`, sign-aware) instead of computing + // `col * val`, which would overflow BIGINT for large operands. The factor's + // sign flips the inequality. Inclusive: a result exactly on max still applies. return "{$quotedColumn} = CASE - WHEN COALESCE({$quotedColumn}, 0) >= :$maxKey THEN :$maxKey - WHEN :$bindKey > 0 AND COALESCE({$quotedColumn}, 0) > :$maxKey / :$bindKey THEN :$maxKey - WHEN :$bindKey < 0 AND COALESCE({$quotedColumn}, 0) < :$maxKey / :$bindKey THEN :$maxKey + WHEN :$bindKey > 0 AND COALESCE({$quotedColumn}, 0) > :$maxKey / :$bindKey THEN COALESCE({$quotedColumn}, 0) + WHEN :$bindKey < 0 AND COALESCE({$quotedColumn}, 0) < :$maxKey / :$bindKey THEN COALESCE({$quotedColumn}, 0) ELSE COALESCE({$quotedColumn}, 0) * :$bindKey END"; } @@ -2004,7 +2001,7 @@ protected function getOperatorSQL(string $column, Operator $operator, array &$bi if (isset($values[1])) { $minKey = $this->registerOperatorBind($binds, $values[1]); return "{$quotedColumn} = CASE - WHEN :$bindKey != 0 AND COALESCE({$quotedColumn}, 0) / :$bindKey <= :$minKey THEN :$minKey + WHEN :$bindKey != 0 AND COALESCE({$quotedColumn}, 0) / :$bindKey < :$minKey THEN COALESCE({$quotedColumn}, 0) ELSE COALESCE({$quotedColumn}, 0) / :$bindKey END"; } @@ -2015,15 +2012,46 @@ protected function getOperatorSQL(string $column, Operator $operator, array &$bi return "{$quotedColumn} = MOD(COALESCE({$quotedColumn}, 0), :$bindKey)"; case Operator::TYPE_POWER: - $bindKey = $this->registerOperatorBind($binds, $values[0] ?? 1); + $exponent = $values[0] ?? 1; + $bindKey = $this->registerOperatorBind($binds, $exponent); if (isset($values[1])) { $maxKey = $this->registerOperatorBind($binds, $values[1]); - return "{$quotedColumn} = CASE - WHEN COALESCE({$quotedColumn}, 0) >= :$maxKey THEN :$maxKey - WHEN COALESCE({$quotedColumn}, 0) <= 1 THEN COALESCE({$quotedColumn}, 0) - WHEN :$bindKey * LOG(COALESCE({$quotedColumn}, 1)) > LOG(:$maxKey) THEN :$maxKey - ELSE POWER(COALESCE({$quotedColumn}, 0), :$bindKey) - END"; + $col = "COALESCE({$quotedColumn}, 0)"; + + // Leave the value unchanged only for undefined inputs, then apply the power if + // the result stays within the max. The exponent is constant, so only the + // undefined guard its value can actually trigger is emitted. + $oddInteger = \floor($exponent) == $exponent && ((int) $exponent) % 2 !== 0; + + $whens = []; + if ($exponent < 0) { + // 0 to a negative power is undefined (POWER would error / return NULL). + $whens[] = "WHEN {$col} = 0 THEN {$col}"; + } + if (\floor($exponent) != $exponent) { + // A negative base to a fractional exponent is not a real number. + $whens[] = "WHEN {$col} < 0 THEN {$col}"; + } + // Cap by magnitude via logarithms so POWER() never runs on a value that would + // overflow (base^exp > max <=> exp * LOG(base) > LOG(max)). + if ($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. + $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"; } return "{$quotedColumn} = POWER(COALESCE({$quotedColumn}, 0), :$bindKey)"; @@ -2043,16 +2071,10 @@ protected function getOperatorSQL(string $column, Operator $operator, array &$bi // Array operators case Operator::TYPE_ARRAY_APPEND: - if (\count($values) > self::MAX_ARRAY_OPERATOR_SIZE) { - throw new DatabaseException("Array size " . \count($values) . " exceeds maximum allowed size of " . self::MAX_ARRAY_OPERATOR_SIZE . " for array operations"); - } $bindKey = $this->registerOperatorBind($binds, json_encode($values)); return "{$quotedColumn} = JSON_MERGE_PRESERVE(IFNULL({$quotedColumn}, JSON_ARRAY()), :$bindKey)"; case Operator::TYPE_ARRAY_PREPEND: - if (\count($values) > self::MAX_ARRAY_OPERATOR_SIZE) { - throw new DatabaseException("Array size " . \count($values) . " exceeds maximum allowed size of " . self::MAX_ARRAY_OPERATOR_SIZE . " for array operations"); - } $bindKey = $this->registerOperatorBind($binds, json_encode($values)); return "{$quotedColumn} = JSON_MERGE_PRESERVE(:$bindKey, IFNULL({$quotedColumn}, JSON_ARRAY()))"; @@ -2086,9 +2108,6 @@ protected function getOperatorSQL(string $column, Operator $operator, array &$bi ), JSON_ARRAY())"; case Operator::TYPE_ARRAY_INTERSECT: - if (\count($values) > self::MAX_ARRAY_OPERATOR_SIZE) { - throw new DatabaseException("Array size " . \count($values) . " exceeds maximum allowed size of " . self::MAX_ARRAY_OPERATOR_SIZE . " for array operations"); - } $bindKey = $this->registerOperatorBind($binds, json_encode($values)); return "{$quotedColumn} = IFNULL(( SELECT JSON_ARRAYAGG(jt1.value) @@ -2100,9 +2119,6 @@ protected function getOperatorSQL(string $column, Operator $operator, array &$bi ), JSON_ARRAY())"; case Operator::TYPE_ARRAY_DIFF: - if (\count($values) > self::MAX_ARRAY_OPERATOR_SIZE) { - throw new DatabaseException("Array size " . \count($values) . " exceeds maximum allowed size of " . self::MAX_ARRAY_OPERATOR_SIZE . " for array operations"); - } $bindKey = $this->registerOperatorBind($binds, json_encode($values)); return "{$quotedColumn} = IFNULL(( SELECT JSON_ARRAYAGG(jt1.value) @@ -2115,14 +2131,6 @@ protected function getOperatorSQL(string $column, Operator $operator, array &$bi case Operator::TYPE_ARRAY_FILTER: $condition = $values[0] ?? 'equal'; - $validConditions = [ - 'equal', 'notEqual', // Comparison - 'greaterThan', 'greaterThanEqual', 'lessThan', 'lessThanEqual', // Numeric - 'isNull', 'isNotNull' // Null checks - ]; - if (!in_array($condition, $validConditions, true)) { - throw new DatabaseException("Invalid filter condition: {$condition}. Must be one of: " . implode(', ', $validConditions)); - } $filterValue = $values[1] ?? null; $conditionKey = $this->registerOperatorBind($binds, $condition); $valueKey = $this->registerOperatorBind($binds, $filterValue === null ? null : json_encode($filterValue)); diff --git a/src/Database/Adapter/Memory.php b/src/Database/Adapter/Memory.php index 7760baf4d8..7f9e5fe59e 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; @@ -3467,12 +3468,13 @@ protected function applyOperator(mixed $current, Operator $operator): mixed $max = $values[1] ?? null; $base = \is_numeric($current) ? $current + 0 : 0; if ($max !== null) { - // Compare *remaining headroom* against $by so we never - // overflow PHP's int range (which would silently demote - // the result to float and corrupt downstream Range - // validators). - if ($base >= $max || ($max - $base) <= $by) { - return $this->preserveNumericType($base, $max); + // Compare *remaining headroom* against $by so we never overflow PHP's int + // range. Guard: if the RESULT would exceed the max, leave it unchanged. + // Note: we must NOT short-circuit on `$base >= $max` — a negative $by moves + // the value down, so an already-over-max base can still land within bound + // (e.g. 52 + (-5) = 47 <= 50 must apply). + if (($max - $base) < $by) { + return $this->preserveNumericType($base, $base); } } @@ -3483,8 +3485,10 @@ protected function applyOperator(mixed $current, Operator $operator): mixed $min = $values[1] ?? null; $base = \is_numeric($current) ? $current + 0 : 0; if ($min !== null) { - if ($base <= $min || ($base - $min) <= $by) { - return $this->preserveNumericType($base, $min); + // Guard: leave unchanged only if the RESULT would go below min. Don't + // short-circuit on `$base <= $min` — a negative $by moves the value up. + if (($base - $min) < $by) { + return $this->preserveNumericType($base, $base); } } @@ -3494,8 +3498,12 @@ protected function applyOperator(mixed $current, Operator $operator): mixed $by = $values[0] ?? 1; $max = $values[1] ?? null; $base = \is_numeric($current) ? $current + 0 : 0; + $result = $base * $by; + if ($max !== null && $result > $max) { + return $this->preserveNumericType($base, $base); + } - return $this->applyNumericLimit($base * $by, $max, true); + return $this->preserveNumericType($base, $result); case Operator::TYPE_DIVIDE: $by = $values[0] ?? 1; @@ -3504,8 +3512,12 @@ protected function applyOperator(mixed $current, Operator $operator): mixed return $current; } $base = \is_numeric($current) ? $current + 0 : 0; + $result = $base / $by; + if ($min !== null && $result < $min) { + return $this->preserveNumericType($base, $base); + } - return $this->applyNumericLimit($base / $by, $min, false); + return $this->preserveNumericType($base, $result); case Operator::TYPE_MODULO: $by = $values[0] ?? 1; @@ -3520,8 +3532,29 @@ protected function applyOperator(mixed $current, Operator $operator): mixed $by = $values[0] ?? 1; $max = $values[1] ?? null; $base = \is_numeric($current) ? $current + 0 : 0; + if ($max !== null) { + // Leave the value unchanged for undefined inputs (0 to a negative power, or a + // negative base to a fractional exponent) — they produce INF/NaN, not a number. + if (($base == 0 && $by < 0) || ($base < 0 && \floor($by) != $by)) { + return $this->preserveNumericType($base, $base); + } + $result = $base ** $by; + // A result that overflows (INF) or exceeds the max also leaves the value as-is. + if (!\is_finite($result) || $result > $max) { + return $this->preserveNumericType($base, $base); + } + + return $this->preserveNumericType($base, $result); + } + + // 0 to a negative power, or a negative base to a fractional exponent, is not a real + // number. Fail loudly with a clear exception rather than storing INF/NaN. + $result = $base ** $by; + if (!\is_finite($result)) { + throw new LimitException('Value out of range'); + } - return $this->applyNumericLimit($base ** $by, $max, true); + return $this->preserveNumericType($base, $result); case Operator::TYPE_STRING_CONCAT: return ((string) ($current ?? '')).(string) ($values[0] ?? ''); diff --git a/src/Database/Adapter/Mongo.php b/src/Database/Adapter/Mongo.php index b23073f4f2..7066fad692 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; @@ -1891,9 +1891,30 @@ 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]]; + $exponent = $values[0]; + $expr = ['$pow' => [$base, $exponent]]; if (isset($values[1])) { - $expr = ['$min' => [$expr, $values[1]]]; + // Apply the power only if the result stays within the max; otherwise leave the + // value unchanged. Overflow yields Infinity, which is greater than the max, so + // it correctly stays put. + $expr = ['$cond' => [['$lte' => [$expr, $values[1]]], $expr, $base]]; + + // Never compute $pow for an undefined input (0 to a negative power, or a + // negative base to a fractional exponent): it yields NaN, which Mongo orders + // below every number, so a plain `<= max` check would wrongly apply it. The + // exponent is constant, so only guard the base condition it can actually trigger. + $guards = []; + if ($exponent < 0) { + $guards[] = ['$eq' => [$base, 0]]; + } + if (\floor($exponent) != $exponent) { + $guards[] = ['$lt' => [$base, 0]]; + } + if (!empty($guards)) { + $undefined = \count($guards) === 1 ? $guards[0] : ['$or' => $guards]; + $expr = ['$cond' => [$undefined, $base, $expr]]; + } } return $expr; @@ -4013,6 +4034,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/MySQL.php b/src/Database/Adapter/MySQL.php index bc7631f0c2..6a43047957 100644 --- a/src/Database/Adapter/MySQL.php +++ b/src/Database/Adapter/MySQL.php @@ -298,16 +298,10 @@ 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"); - } $bindKey = $this->registerOperatorBind($binds, json_encode($values)); return "{$quotedColumn} = JSON_MERGE_PRESERVE(IFNULL({$quotedColumn}, JSON_ARRAY()), :$bindKey)"; case Operator::TYPE_ARRAY_PREPEND: - if (\count($values) > self::MAX_ARRAY_OPERATOR_SIZE) { - throw new DatabaseException("Array size " . \count($values) . " exceeds maximum allowed size of " . self::MAX_ARRAY_OPERATOR_SIZE . " for array operations"); - } $bindKey = $this->registerOperatorBind($binds, json_encode($values)); return "{$quotedColumn} = JSON_MERGE_PRESERVE(:$bindKey, IFNULL({$quotedColumn}, JSON_ARRAY()))"; diff --git a/src/Database/Adapter/Postgres.php b/src/Database/Adapter/Postgres.php index 1aea7f96d0..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; } @@ -2121,6 +2113,12 @@ 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); @@ -2528,8 +2526,7 @@ protected function getOperatorSQL(string $column, Operator $operator, array &$bi if (isset($values[1])) { $maxKey = $this->registerOperatorBind($binds, $values[1]); 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"; } @@ -2540,8 +2537,7 @@ protected function getOperatorSQL(string $column, Operator $operator, array &$bi if (isset($values[1])) { $minKey = $this->registerOperatorBind($binds, $values[1]); 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"; } @@ -2552,9 +2548,7 @@ protected function getOperatorSQL(string $column, Operator $operator, array &$bi if (isset($values[1])) { $maxKey = $this->registerOperatorBind($binds, $values[1]); 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"; } @@ -2565,7 +2559,7 @@ protected function getOperatorSQL(string $column, Operator $operator, array &$bi if (isset($values[1])) { $minKey = $this->registerOperatorBind($binds, $values[1]); 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"; } @@ -2576,15 +2570,46 @@ 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]); - 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 - 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(). + $oddInteger = \floor($exponent) == $exponent && ((int) $exponent) % 2 !== 0; + + $whens = []; + if ($exponent < 0) { + $whens[] = "WHEN {$col} = 0 THEN {$col}"; + } + if (\floor($exponent) != $exponent) { + $whens[] = "WHEN {$col} < 0 THEN {$col}"; + } + // Cap by magnitude via logarithms so POWER() never runs on a value that would + // overflow (base^exp > max <=> exp * LN(base) > LN(max)). + 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. + $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"; } return "{$quotedColumn} = POWER(COALESCE({$columnRef}, 0), :$bindKey)"; @@ -2661,14 +2686,6 @@ protected function getOperatorSQL(string $column, Operator $operator, array &$bi case Operator::TYPE_ARRAY_FILTER: $condition = $values[0] ?? 'equal'; - $validConditions = [ - 'equal', 'notEqual', // Comparison - 'greaterThan', 'greaterThanEqual', 'lessThan', 'lessThanEqual', // Numeric - 'isNull', 'isNotNull' // Null checks - ]; - if (!in_array($condition, $validConditions, true)) { - throw new DatabaseException("Invalid filter condition: {$condition}. Must be one of: " . implode(', ', $validConditions)); - } $filterValue = $values[1] ?? null; $conditionKey = $this->registerOperatorBind($binds, $condition); $valueKey = $this->registerOperatorBind($binds, $filterValue === null ? null : json_encode($filterValue)); diff --git a/src/Database/Adapter/Redis.php b/src/Database/Adapter/Redis.php index 87e0fe89b6..fa60c3a935 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; @@ -4041,8 +4042,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 +4058,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 +4071,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 +4085,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 +4105,30 @@ protected function applyOperator(mixed $current, Operator $operator): mixed $by = $values[0] ?? 1; $max = $values[1] ?? null; $base = \is_numeric($current) ? $current + 0 : 0; + if ($max !== null) { + // Leave the value unchanged for undefined inputs (0 to a negative power, or a + // negative base to a fractional exponent) — they produce INF/NaN, not a number. + if (($base == 0 && $by < 0) || ($base < 0 && \floor($by) != $by)) { + return $this->preserveNumericType($base, $base); + } + $result = $base ** $by; + // A result that overflows (INF) or exceeds the max also leaves the value as-is. + if (!\is_finite($result) || $result > $max) { + return $this->preserveNumericType($base, $base); + } + + return $this->preserveNumericType($base, $result); + } + + // 0 to a negative power, or a negative base to a fractional exponent, is not a real + // number. Fail loudly with a clear exception rather than storing INF/NaN (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->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/SQL.php b/src/Database/Adapter/SQL.php index 88b739ca93..03aefd5bc7 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. */ @@ -516,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++; @@ -575,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 1a06f5cd62..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; } @@ -2032,8 +2024,7 @@ protected function getOperatorSQL(string $column, Operator $operator, array &$bi if (isset($values[1])) { $maxKey = $this->registerOperatorBind($binds, $values[1]); 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"; } @@ -2046,8 +2037,7 @@ protected function getOperatorSQL(string $column, Operator $operator, array &$bi if (isset($values[1])) { $minKey = $this->registerOperatorBind($binds, $values[1]); 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"; } @@ -2060,9 +2050,7 @@ protected function getOperatorSQL(string $column, Operator $operator, array &$bi if (isset($values[1])) { $maxKey = $this->registerOperatorBind($binds, $values[1]); 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"; } @@ -2075,7 +2063,7 @@ protected function getOperatorSQL(string $column, Operator $operator, array &$bi if (isset($values[1])) { $minKey = $this->registerOperatorBind($binds, $values[1]); return "{$quotedColumn} = CASE - WHEN :$bindKey != 0 AND COALESCE({$quotedColumn}, 0) / :$bindKey <= :$minKey THEN :$minKey + WHEN :$bindKey != 0 AND COALESCE({$quotedColumn}, 0) / :$bindKey < :$minKey THEN COALESCE({$quotedColumn}, 0) ELSE COALESCE({$quotedColumn}, 0) / :$bindKey END"; } @@ -2095,16 +2083,47 @@ 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]); - 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 - ELSE POWER(COALESCE({$quotedColumn}, 0), :$bindKey) - END"; + $col = "COALESCE({$quotedColumn}, 0)"; + + // Leave the value unchanged only for undefined inputs, then apply the power if + // the result stays within the max. The exponent is constant, so only the + // undefined guard its value can actually trigger is emitted. + $oddInteger = \floor($exponent) == $exponent && ((int) $exponent) % 2 !== 0; + + $whens = []; + if ($exponent < 0) { + // 0 to a negative power is undefined. + $whens[] = "WHEN {$col} = 0 THEN {$col}"; + } + if (\floor($exponent) != $exponent) { + // A negative base to a fractional exponent is not a real number. + $whens[] = "WHEN {$col} < 0 THEN {$col}"; + } + // Cap by magnitude via logarithms so POWER() never runs on a value that would + // overflow (base^exp > max <=> exp * LN(base) > LN(max)). + 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. + $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"; } return "{$quotedColumn} = POWER(COALESCE({$quotedColumn}, 0), :$bindKey)"; @@ -2128,9 +2147,6 @@ 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"); - } $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 @@ -2145,9 +2161,6 @@ 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"); - } $bindKey = $this->registerOperatorBind($binds, json_encode($values)); // SQLite: prepend by extracting and recombining with new elements first return "{$quotedColumn} = ( @@ -2216,9 +2229,6 @@ 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"); - } $bindKey = $this->registerOperatorBind($binds, json_encode($values)); // SQLite: keep only values that exist in both arrays return "{$quotedColumn} = ( @@ -2229,9 +2239,6 @@ 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"); - } $bindKey = $this->registerOperatorBind($binds, json_encode($values)); // SQLite: remove values that exist in the comparison array return "{$quotedColumn} = ( diff --git a/src/Database/Operator.php b/src/Database/Operator.php index b60b49fb62..c43d767097 100644 --- a/src/Database/Operator.php +++ b/src/Database/Operator.php @@ -66,6 +66,21 @@ 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; + + /** + * 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 46f1b8db37..655d5d511d 100644 --- a/src/Database/Validator/Operator.php +++ b/src/Database/Validator/Operator.php @@ -150,6 +150,26 @@ 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. + // 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) + ) { + $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) { case DatabaseOperator::TYPE_INCREMENT: case DatabaseOperator::TYPE_DECREMENT: @@ -390,13 +410,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 3f365ed37c..89c64bee5b 100644 --- a/tests/e2e/Adapter/Scopes/OperatorTests.php +++ b/tests/e2e/Adapter/Scopes/OperatorTests.php @@ -5,7 +5,8 @@ use Utopia\Database\Database; use Utopia\Database\DateTime; use Utopia\Database\Document; -use Utopia\Database\Exception as DatabaseException; +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; use Utopia\Database\Helpers\Permission; @@ -459,7 +460,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 +497,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 +532,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 +586,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 +596,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 +606,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 +616,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 +626,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 +661,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 +671,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 +717,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()); } @@ -762,13 +763,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 +782,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); } @@ -865,7 +866,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()); } @@ -1216,7 +1217,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 +1273,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 +1318,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 +1353,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 +1417,492 @@ 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); + } + + /** + * 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); + } + + /** + * 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 + { + $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); + + // [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, + ])); + + $caught = false; + try { + $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); + + // 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'); + $this->assertEquals($start, $stored, "{$id}: value changed even though the update raised a LimitException"); + } + + if ($caught === false) { + // 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). 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)); + } + } + + // A valid unbounded power still computes normally on every adapter: 2^3 = 8. + $database->createDocument($collectionId, new Document([ + '$id' => 'valid', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'value' => 2.0, + ])); + $database->updateDocument($collectionId, 'valid', new Document(['value' => Operator::power(3)])); + $this->assertEquals(8.0, $database->getDocument($collectionId, 'valid')->getAttribute('value')); + + $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 + ['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) + ['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]) { + $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. + */ + 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'], + ])); + + $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), + // 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) { + try { + $database->updateDocument($collectionId, 'doc', new Document(['tags' => $operator])); + $this->fail("Expected an exception for {$name} exceeding the array operator size limit"); + } catch (StructureException $e) { + $this->assertStringContainsString('exceeds maximum allowed size', $e->getMessage()); + } + } + + $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 (StructureException $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); } @@ -2630,9 +3116,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 +3186,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 +3198,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 +3348,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); } @@ -3445,7 +3931,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()); } @@ -3842,13 +4328,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 +4342,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); }