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
42 changes: 4 additions & 38 deletions src/ConnectionInternals.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
use stdClass;

// import built-ins so calls resolve at compile time instead of per-call lookups; NamespacedCallsTest keeps this list exact
use function addcslashes, array_column, array_count_values, array_filter, array_flip, array_is_list, array_key_exists, array_keys, array_map, array_unique, array_values, count, explode, get_debug_type, get_object_vars, implode, is_array, is_bool, is_finite, is_float, is_int, is_object, is_string, preg_grep, preg_match, preg_match_all, preg_replace, preg_replace_callback, str_contains, str_replace, str_starts_with, strlen, strspn, strtoupper, substr, trim, var_export;
use function addcslashes, array_column, array_count_values, array_filter, array_flip, array_is_list, array_key_exists, array_keys, array_map, array_unique, array_values, count, explode, get_debug_type, get_object_vars, implode, is_array, is_bool, is_float, is_int, is_object, is_string, preg_grep, preg_match, preg_match_all, preg_replace, preg_replace_callback, str_contains, str_replace, str_starts_with, strlen, strspn, strtoupper, substr, trim, var_export;
use const MYSQLI_ASSOC, MYSQLI_NUM;

/**
Expand Down Expand Up @@ -827,7 +827,7 @@ public function escape(string|int|float|null|SmartString $input, bool $escapeLik

// Floats get the same exact literal as every other escape path
if (is_float($input)) {
$input = $this->floatToSql($input, 'escape() value');
$input = DB::floatToSql($input, 'escape() value');
}

// Escape LIKE wildcards first, on the raw value: MySQL decodes a pattern twice
Expand Down Expand Up @@ -882,7 +882,7 @@ public function escapef(string $format, mixed ...$values): string
} elseif ($value === null) {
$sql .= 'NULL';
} elseif (is_float($value)) {
$sql .= $this->floatToSql($value, 'escapef() value');
$sql .= DB::floatToSql($value, 'escapef() value');
} elseif (is_bool($value)) {
$sql .= $value ? 'TRUE' : 'FALSE';
} elseif ($value instanceof RawSql) {
Expand Down Expand Up @@ -994,7 +994,7 @@ private function escapeValue(mixed $value, string $context = 'value'): string
return 'NULL';
}
if (is_float($value)) {
return $this->floatToSql($value, $context);
return DB::floatToSql($value, $context);
}
if (is_bool($value)) {
return $value ? 'TRUE' : 'FALSE';
Expand All @@ -1005,40 +1005,6 @@ private function escapeValue(mixed $value, string $context = 'value'): string
throw new InvalidArgumentException("Unsupported type for $context: " . get_debug_type($value));
}

/**
* Convert a finite float to the shortest SQL literal that parses back to the
* identical double. Every float ZenDB writes into SQL goes through here, so one
* value has one spelling on every path: placeholders, SET clauses, IN lists,
* escape(), and the like* helpers.
*
* 0.1 + 0.2 → 0.30000000000000004 the value the variable actually holds
* 2.0 → 2.0
* 12345678901234567.0 → 12345678901234568.0 ...567 is not representable; the variable holds ...568
*
* A plain (string) cast rounds to 14 digits and prints values PHP isn't
* actually holding, so writes lose precision and WHERE equality misses
* stored values (MySQL reads '0.3' as a number that won't equal the sum).
*
* The imprecision starts in PHP, not here: floats are binary, so 0.1 + 0.2
* is already 0.30000000000000004 before ZenDB sees it. This function writes
* exactly what PHP has. To store something else, convert before passing:
*
* round(0.1 + 0.2, 2) → 0.3 rounded to a precision you chose
* (string)(0.1 + 0.2) → '0.3' strings pass through untouched...
* (string)12345678901234568.0 → '1.2345678901235E+16' ...but the cast E-notates large floats
*
* For exact values, use ints of the smallest unit (cents, not dollars) or a
* DECIMAL column with string input.
*
* NAN and INF have no SQL literal, so they throw.
*/
private function floatToSql(float $value, string $context = 'value'): string
{
return is_finite($value)
? var_export($value, true) // exact: php.ini serialize_precision, -1 (shortest round-trip) by default since PHP 7.1
: throw new InvalidArgumentException("NAN and INF have no SQL literal, can't escape $context");
}

//endregion
//region Result Processing

Expand Down
38 changes: 37 additions & 1 deletion src/DBInternals.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
use RuntimeException;

// import built-ins so calls resolve at compile time instead of per-call lookups; NamespacedCallsTest keeps this list exact
use function date, htmlspecialchars, preg_match;
use function date, htmlspecialchars, is_finite, preg_match, var_export;
use const ENT_DISALLOWED, ENT_HTML5, ENT_QUOTES, ENT_SUBSTITUTE, MYSQLI_TYPE_BLOB;

/**
Expand Down Expand Up @@ -114,6 +114,42 @@ public static function escapeCSV(array $values): RawSql
return self::connection()->escapeCSV($values);
}

/**
* Convert a finite float to the shortest SQL literal that parses back to the
* identical double. Every float ZenDB writes into SQL goes through here, so one
* value has one spelling on every path: placeholders, SET clauses, IN lists,
* escape(), and the like* helpers.
*
* 0.1 + 0.2 → 0.30000000000000004 the value the variable actually holds
* 2.0 → 2.0
* 12345678901234567.0 → 12345678901234568.0 ...567 is not representable; the variable holds ...568
*
* A plain (string) cast rounds to 14 digits and prints values PHP isn't
* actually holding, so writes lose precision and WHERE equality misses
* stored values (MySQL reads '0.3' as a number that won't equal the sum).
*
* The imprecision starts in PHP, not here: floats are binary, so 0.1 + 0.2
* is already 0.30000000000000004 before ZenDB sees it. This function writes
* exactly what PHP has. To store something else, convert before passing:
*
* round(0.1 + 0.2, 2) → 0.3 rounded to a precision you chose
* (string)(0.1 + 0.2) → '0.3' strings pass through untouched...
* (string)12345678901234568.0 → '1.2345678901235E+16' ...but the cast E-notates large floats
*
* For exact values, use ints of the smallest unit (cents, not dollars) or a
* DECIMAL column with string input.
*
* NAN and INF have no SQL literal, so they throw.
*
* @internal
*/
public static function floatToSql(float $value, string $context = 'value'): string
{
return is_finite($value)
? var_export($value, true) // exact: php.ini serialize_precision, -1 (shortest round-trip) by default since PHP 7.1
: throw new InvalidArgumentException("NAN and INF have no SQL literal, can't escape $context");
}

/**
* HTML-encode a value for safe output, same name and flags as CMS Builder's h().
* ENT_DISALLOWED substitutes code points HTML5 forbids (C1 controls, noncharacters)
Expand Down
58 changes: 58 additions & 0 deletions tests/ValueTypes/FloatToSqlTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);

namespace Itools\ZenDB\Tests\ValueTypes;

use InvalidArgumentException;
use Itools\ZenDB\DB;
use PHPUnit\Framework\TestCase;

/**
* Tests for DB::floatToSql(), the one place ZenDB turns a PHP float into a SQL literal
*
* @covers \Itools\ZenDB\DB::floatToSql
*/
class FloatToSqlTest extends TestCase
{
/**
* @dataProvider finiteFloatProvider
*/
public function testFiniteFloatsUseShortestRoundTripSpelling(float $value, string $expected): void
{
$this->assertSame($expected, DB::floatToSql($value));
}

public static function finiteFloatProvider(): array
{
return [
'simple decimal' => [0.1, '0.1'],
'many digits' => [1234567890.1234567, '1234567890.1234567'],
'binary rounding error' => [0.3 - 0.1, '0.19999999999999998'],
'large exponent' => [1.0E+20, '1.0E+20'],
'small negative exponent' => [-2.5E-7, '-2.5E-7'],
'whole number keeps .0' => [3.0, '3.0'],
'negative zero' => [-0.0, '-0.0'],
];
}

public function testNanThrowsWithContext(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage("can't escape backup value");
DB::floatToSql(NAN, 'backup value');
}

public function testInfThrowsWithContext(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage("can't escape backup value");
DB::floatToSql(INF, 'backup value');
}

public function testNegativeInfThrowsWithDefaultContext(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage("can't escape value");
DB::floatToSql(-INF);
}
}