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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 66 additions & 33 deletions src/Database/Adapter/SQL.php
Original file line number Diff line number Diff line change
Expand Up @@ -124,12 +124,9 @@ abstract class SQL extends Adapter implements Feature\Connection, Feature\RawQue

private const int UNPAIRED_JOINED_ROWS = 4;

protected object $pdo;
private const string ROUND_TRIP_PRECISION = '-1';

/**
* Controls how many fractional digits are used when binding float parameters.
*/
protected int $floatPrecision = 17;
protected object $pdo;

/**
* Lazily constructed AttributeMap shared by every builder's scope.
Expand Down Expand Up @@ -209,11 +206,57 @@ public function getDriver(): object
}

/**
* Helper to format a float value according to configured precision for binding/logging.
* A decimal that reads back as exactly this double, in as few digits as PHP writes it when they suffice. Casts
* and `%H` ignore the locale, and a double needs at most 17 significant digits to round-trip.
*/
protected function formatFloat(float $value): string
{
$formatted = (string) $value;
if ((float) $formatted === $value) {
return $formatted;
}

$formatted = \sprintf('%.16H', $value);

return (float) $formatted === $value ? $formatted : \sprintf('%.17H', $value);
}

/**
* Binds a value as its PDO type, and a double as its exact decimal: PDO would write it at the `precision` ini.
*
* @param PDOStatement|DatabasePDOStatement|PDOStatementProxy $statement
*/
protected function bind(object $statement, int|string $parameter, mixed $value): void
{
if (\is_float($value)) {
$statement->bindValue($parameter, $this->formatFloat($value), PDO::PARAM_STR);

return;
}

$statement->bindValue($parameter, $value, $this->getPdoType($value));
}

/**
* Runs a fetch at the precision that round-trips every double. PDO stringifies a double it fetches at the
* `precision` ini, 14 significant digits by default.
*
* @template T
*
* @param callable(): T $fetch
* @return T
*/
protected function getFloatPrecision(float $value): string
protected function fetchExactly(callable $fetch): mixed
{
return sprintf('%.'.$this->floatPrecision.'F', $value);
$precision = \ini_set('precision', self::ROUND_TRIP_PRECISION);

try {
return $fetch();
} finally {
if ($precision !== false) {
\ini_set('precision', $precision);
}
}
}

#[\Override]
Expand Down Expand Up @@ -621,12 +664,12 @@ public function getDocument(Document $collection, string $id, array $queries = [
try {
$statement = $this->prepareStatement($sql, Event::DocumentRead);
foreach ($bindings as $parameter => $value) {
$statement->bindValue($parameter, $value, $this->getPdoType($value));
$this->bind($statement, $parameter, $value);
}
$this->describeStatement($statement, $bindings, $name);
$this->execute($statement);
/** @var array<string, mixed>|false $row */
$row = $statement->fetch(PDO::FETCH_ASSOC);
$row = $this->fetchExactly(static fn (): mixed => $statement->fetch(PDO::FETCH_ASSOC));
} catch (PDOException $e) {
$exception = $e;
} finally {
Expand Down Expand Up @@ -984,7 +1027,7 @@ private function readRows(string $name, array $documents, array $columns): array
$statement = $this->executeResult($builder->build(), Event::DocumentRead);
$this->execute($statement);
/** @var list<list<mixed>> $rows */
$rows = $statement->fetchAll(PDO::FETCH_NUM);
$rows = $this->fetchExactly(static fn (): array => $statement->fetchAll(PDO::FETCH_NUM));
$statement->closeCursor();

return $rows;
Expand Down Expand Up @@ -1536,7 +1579,7 @@ public function find(Document $collection, array $queries = [], ?int $limit = 25
$this->describeStatement($statement, [], $name);
$this->execute($statement);
/** @var array<int, array<string, mixed>> $rows */
$rows = $statement->fetchAll();
$rows = $this->fetchExactly(static fn (): array => $statement->fetchAll());
} catch (PDOException $e) {
$exception = $e;
} finally {
Expand Down Expand Up @@ -1837,14 +1880,14 @@ public function rawQuery(string $query, array $bindings = []): array
try {
$statement = $this->prepareStatement($query);
foreach ($bindings as $i => $value) {
$statement->bindValue($i + 1, $value, $this->getPdoType($value));
$this->bind($statement, $i + 1, $value);
}
$this->execute($statement);
} catch (PDOException $e) {
throw $this->processException($e);
}

$results = $statement->fetchAll();
$results = $this->fetchExactly(static fn (): array => $statement->fetchAll());
$statement->closeCursor();

$documents = [];
Expand Down Expand Up @@ -3552,7 +3595,7 @@ public function rawMutation(string $query, array $bindings = []): int
try {
$statement = $this->prepareStatement($query);
foreach ($bindings as $i => $value) {
$statement->bindValue($i + 1, $value, $this->getPdoType($value));
$this->bind($statement, $i + 1, $value);
}
$this->execute($statement);
} catch (PDOException $e) {
Expand Down Expand Up @@ -3661,10 +3704,6 @@ protected function writeContext(array $skipPermissions = []): WriteContext
* Prepares the SQL statement and binds positional parameters from the Statement.
* Does NOT call execute() - the caller is responsible for that.
*
* Floats are bound as 7.x bound them: a find() binds them in fixed-point notation, every other statement as
* PHP writes them, so a write keeps a magnitude fixed point would round to zero. A bulk delete binds them as a
* find() does, so its filters match the rows the find() of the documents it deletes matched.
*
* @param string $collection The collection the statement reads or writes, for the profiler
* @return PDOStatement|DatabasePDOStatement|PDOStatementProxy
*/
Expand All @@ -3676,13 +3715,7 @@ protected function executeResult(Statement $result, ?Event $event = null, string
if (\is_bool($value) && $this->supports(Capability::IntegerBooleans)) {
$value = (int) $value;
}
if (\is_float($value) && ($event === Event::DocumentFind || $event === Event::DocumentsDelete)) {
$prepared->bindValue($i + 1, $this->getFloatPrecision($value), PDO::PARAM_STR);
} elseif (\is_float($value)) {
$prepared->bindValue($i + 1, $value, PDO::PARAM_STR);
} else {
$prepared->bindValue($i + 1, $value, $this->getPdoType($value));
}
$this->bind($prepared, $i + 1, $value);
}

return $prepared;
Expand Down Expand Up @@ -6266,7 +6299,7 @@ private function runSelect(Statement $result, Event $event, string $collection):
$statement = $this->executeResult($result, $event, $collection);
$this->execute($statement);
/** @var array<int, array<string, mixed>> $results */
$results = $statement->fetchAll(PDO::FETCH_ASSOC);
$results = $this->fetchExactly(static fn (): array => $statement->fetchAll(PDO::FETCH_ASSOC));
} catch (PDOException $e) {
$exception = $e;
} finally {
Expand Down Expand Up @@ -6520,34 +6553,34 @@ protected function bindOperatorParameters(object $statement, Operator $operator,
case OperatorType::Divide:
$value = $values[0] ?? 1;
$bindKey = "op_{$bindIndex}";
$statement->bindValue(':'.$bindKey, $value, $this->getPdoType($value));
$this->bind($statement, ':'.$bindKey, $value);
$bindIndex++;

if (isset($values[1])) {
$limitKey = "op_{$bindIndex}";
$limit = self::exactLimit($values[1]);
$statement->bindValue(':'.$limitKey, $limit, $this->getPdoType($limit));
$this->bind($statement, ':'.$limitKey, $limit);
$bindIndex++;
}
break;

case OperatorType::Modulo:
$value = $values[0] ?? 1;
$bindKey = "op_{$bindIndex}";
$statement->bindValue(':'.$bindKey, $value, $this->getPdoType($value));
$this->bind($statement, ':'.$bindKey, $value);
$bindIndex++;
break;

case OperatorType::Power:
$value = $values[0] ?? 1;
$bindKey = "op_{$bindIndex}";
$statement->bindValue(':'.$bindKey, $value, $this->getPdoType($value));
$this->bind($statement, ':'.$bindKey, $value);
$bindIndex++;

if (isset($values[1])) {
$maxKey = "op_{$bindIndex}";
$limit = self::exactLimit($values[1]);
$statement->bindValue(':'.$maxKey, $limit, $this->getPdoType($limit));
$this->bind($statement, ':'.$maxKey, $limit);
$bindIndex++;
}
break;
Expand Down Expand Up @@ -6596,7 +6629,7 @@ protected function bindOperatorParameters(object $statement, Operator $operator,
if (is_array($value)) {
$value = json_encode($value);
}
$statement->bindValue(':'.$bindKey, $value, $this->getPdoType($value));
$this->bind($statement, ':'.$bindKey, $value);
$bindIndex++;
break;

Expand Down
4 changes: 2 additions & 2 deletions src/Database/Adapter/SQLite.php
Original file line number Diff line number Diff line change
Expand Up @@ -1805,7 +1805,7 @@ protected function bindOperatorParameters(object $statement, Operator $operator,
if (in_array($filterType, $comparisonTypes)) {
$bindKey = "op_{$bindIndex}";
$value = (is_bool($filterValue)) ? (int) $filterValue : $filterValue;
$statement->bindValue(":{$bindKey}", $value, $this->getPdoType($value));
$this->bind($statement, ":{$bindKey}", $value);
$bindIndex++;
}
}
Expand Down Expand Up @@ -2408,7 +2408,7 @@ protected function executeUpsertBatch(
);

foreach ($bindValues as $key => $binding) {
$statement->bindValue($key, $binding, $this->getPdoType($binding));
$this->bind($statement, $key, $binding);
}

$opIndexForBinding = 0;
Expand Down
27 changes: 27 additions & 0 deletions src/Database/Validator/Finite.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?php

namespace Utopia\Database\Validator;

use Utopia\Validator\FloatValidator;

/**
* A float or integer that is neither infinite nor NaN, which no engine stores or compares as a double.
*/
class Finite extends FloatValidator
{
#[\Override]
public function getDescription(): string
{
return 'Value must be a finite float';
}

#[\Override]
public function isValid(mixed $value): bool
{
if (! parent::isValid($value)) {
return false;
}

return \is_numeric($value) && \is_finite((float) $value);
}
}
4 changes: 2 additions & 2 deletions src/Database/Validator/Query/Filter.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use Utopia\Database\Query;
use Utopia\Database\Validator\BigInt;
use Utopia\Database\Validator\Datetime as DatetimeValidator;
use Utopia\Database\Validator\Finite;
use Utopia\Database\Validator\Geometry;
use Utopia\Database\Validator\ObjectPath;
use Utopia\Database\Validator\Query\Joined\Attributes;
Expand All @@ -18,7 +19,6 @@
use Utopia\Query\Query as BaseQuery;
use Utopia\Query\Schema\ColumnType;
use Utopia\Validator\Boolean;
use Utopia\Validator\FloatValidator;
use Utopia\Validator\Integer;
use Utopia\Validator\Text;

Expand Down Expand Up @@ -263,7 +263,7 @@ protected function isValidAttributeAndValues(string $attribute, array $values, M

case ColumnType::Float:
case ColumnType::Double:
$validator = new FloatValidator();
$validator = new Finite();
break;

case ColumnType::Boolean:
Expand Down
4 changes: 2 additions & 2 deletions src/Database/Validator/Query/Having.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
namespace Utopia\Database\Validator\Query;

use Utopia\Database\Query;
use Utopia\Database\Validator\Finite;
use Utopia\Query\Method;
use Utopia\Query\Query as BaseQuery;
use Utopia\Validator\FloatValidator;

/**
* Validates having query methods: each condition compares an aggregate alias declared in the same
Expand Down Expand Up @@ -196,7 +196,7 @@ private function isValidAliasCondition(Query $condition, BaseQuery $aggregation,
return false;
}

$number = new FloatValidator();
$number = new Finite();
foreach ($values as $value) {
if (! $number->isValid($value)) {
$this->message = 'Query value is invalid for aggregate alias "'.$alias.'"';
Expand Down
Loading
Loading