diff --git a/src/ConnectionInternals.php b/src/ConnectionInternals.php index d7c44dd..9232e8a 100644 --- a/src/ConnectionInternals.php +++ b/src/ConnectionInternals.php @@ -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; /** @@ -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 @@ -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) { @@ -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'; @@ -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 diff --git a/src/DBInternals.php b/src/DBInternals.php index fefd024..c4fc641 100644 --- a/src/DBInternals.php +++ b/src/DBInternals.php @@ -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; /** @@ -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) diff --git a/tests/ValueTypes/FloatToSqlTest.php b/tests/ValueTypes/FloatToSqlTest.php new file mode 100644 index 0000000..f01ec22 --- /dev/null +++ b/tests/ValueTypes/FloatToSqlTest.php @@ -0,0 +1,58 @@ +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); + } +}