mysql-haskell-1.5.0: src/Database/MySQL/Protocol/FloatingPoint.hs
{-|
Module : Database.MySQL.Protocol.FloatingPoint
Description : FLOAT and DOUBLE text, correctly rounded
Copyright : (c) Jappie Klooster, 2026
License : BSD3
Maintainer : hi@jappie.me
Stability : experimental
Portability : PORTABLE
The text protocol sends FLOAT and DOUBLE values as decimal text, large ones in
exponent form such as @1e20@. 'readDouble' and 'readFloat' give the nearest
value, as 'read' does, and are fast for the short numbers MySQL usually writes.
See Note [Correctly rounded floating point text].
-}
module Database.MySQL.Protocol.FloatingPoint
( readDouble
, readFloat
) where
import Data.ByteString (ByteString)
import qualified Data.ByteString as B
import qualified Data.ByteString.Lex.Fractional as LexFrac
import Data.Scientific (Scientific, toRealFloat)
import Data.Word (Word64, Word8)
import qualified Unwitch.Convert.Word64 as Word64
import qualified Unwitch.Convert.Word8 as Word8
{- Note [Correctly rounded floating point text]
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Scaling decimal digits by @10 ^^ e@ in floating point rounds more than once.
bytestring-lexing's readExponential missed the nearest Double for 83% of
20,000 shown doubles, and its readDecimal, which also stops at an @e@ and so
read @1e20@ as 1, missed 2 of 243 plain decimals. Reading the text exactly as a
'Scientific' and rounding once with 'toRealFloat' missed none, but cost about
14,000 instructions per DOUBLE field.
Decision: Clinger's fast path first, then the exact way; the alternatives
above were wrong or slow. The fast path avoids the cost: when the significant
digits, read as an integer, lie in the exact integer range of a Double (below
2^53, where unwitch's 'Word64.toDouble' converts and every integer is exact)
and the power of ten is at most 22 either way, that power is exact as well, so
one multiplication or division rounds once and gives the nearest Double. A
Float takes integers below 2^24 and powers up to 10. A number with more than 19
significant digits does not fit a 'Word64' and has no significand at all, so
it never reaches the fast path. MySQL writes most values within these bounds;
the rest go the exact way.
-}
-- | The nearest 'Double' to the decimal number at the start of the bytes:
-- @-?digits(.digits)?([eE][+-]?digits)?@. Bytes after it are ignored.
readDouble :: ByteString -> Maybe Double
readDouble bytes = do
decimal <- scanDecimal bytes
case fastSignificand Word64.toDouble 22 decimal of
Just exactDigits -> Just (signed decimal (scaleExactly exactDigits (decimalExponent decimal)))
Nothing -> exactly bytes
-- | 'readDouble' for 'Float'.
readFloat :: ByteString -> Maybe Float
readFloat bytes = do
decimal <- scanDecimal bytes
case fastSignificand Word64.toFloat 10 decimal of
Just exactDigits -> Just (signed decimal (scaleExactly exactDigits (decimalExponent decimal)))
Nothing -> exactly bytes
-- | The significand as an exact @a@, when the fast path applies: it has at
-- most 19 digits, @toExactInteger@ converts it (unwitch's conversions fail
-- outside the exact integer range of the type), and the power of ten is
-- within @maxPower@ either way. 'Nothing' sends the number the exact way, so
-- the conversion's failure is a choice of path, not a lost error.
-- See Note [Correctly rounded floating point text].
fastSignificand :: (Word64 -> Either overflow a) -> Int -> DecimalText -> Maybe a
fastSignificand toExactInteger maxPower decimal = do
digits <- decimalSignificand decimal
if abs (decimalExponent decimal) <= maxPower
then either (const Nothing) Just (toExactInteger digits)
else Nothing
-- | The significand times @10 ^ powerOfTen@, with one rounding: the caller
-- guarantees that the significand and the power of ten are both exact.
scaleExactly :: RealFloat a => a -> Int -> a
scaleExactly exactDigits powerOfTen =
if powerOfTen >= 0
then exactDigits * 10 ^ powerOfTen
else exactDigits / 10 ^ negate powerOfTen
signed :: Num a => DecimalText -> a -> a
signed decimal = signed' (decimalSign decimal)
signed' :: Num a => Sign -> a -> a
signed' sign magnitude = case sign of
Positive -> magnitude
Negative -> negate magnitude
-- | The text read exactly as a 'Scientific', then rounded once.
exactly :: RealFloat a => ByteString -> Maybe a
exactly bytes = toRealFloat <$> exactScientific bytes
exactScientific :: ByteString -> Maybe Scientific
exactScientific bytes = fst <$> LexFrac.readSigned LexFrac.readExponential bytes
data Sign = Positive | Negative
-- | A decimal number in text, as far as the fast path needs it.
data DecimalText = DecimalText
{ decimalSign :: !Sign
, decimalSignificand :: !(Maybe Word64)
-- ^ the digits from the first non-zero one on, without the point, as one
-- integer; 'Nothing' when there are more than 19, too many for a 'Word64'
, decimalExponent :: !Int
-- ^ the power of ten the significand is scaled by; 'maxBound' when the
-- written exponent has more digits than the fast path takes
}
-- | The number at the start of the bytes, or 'Nothing' when they do not
-- start with one.
scanDecimal :: ByteString -> Maybe DecimalText
scanDecimal bytes =
let (sign, afterSign) = splitSign bytes
(integerPart, afterInteger) = B.span isDigit afterSign
(fractionPart, afterFraction) = splitFraction afterInteger
digits = integerPart <> fractionPart
significant = B.dropWhile (== 48) digits -- '0'
in if B.null integerPart
then Nothing
else Just DecimalText
{ decimalSign = sign
, decimalSignificand =
if B.length significant <= 19
then Just (B.foldl' appendDigit 0 significant)
else Nothing
, decimalExponent = writtenExponent afterFraction `minusDigits` B.length fractionPart
}
-- | The exponent after @e@ or @E@, 0 when there is none; 'maxBound' when it
-- has more than 6 digits, which no FLOAT or DOUBLE needs.
writtenExponent :: ByteString -> Int
writtenExponent bytes = case B.uncons bytes of
Just (marker, rest) ->
if marker == 101 || marker == 69 -- 'e', 'E'
then exponentDigits rest
else 0
Nothing -> 0
exponentDigits :: ByteString -> Int
exponentDigits bytes =
let (sign, afterSign) = splitSign bytes
digits = B.takeWhile isDigit afterSign
in if | B.null digits -> 0
| B.length digits > 6 -> maxBound
| otherwise -> signed' sign (B.foldl' appendDigitInt 0 digits)
-- | A leading @-@ or @+@, and the bytes after it.
splitSign :: ByteString -> (Sign, ByteString)
splitSign bytes = case B.uncons bytes of
Nothing -> (Positive, bytes)
Just (byte, rest) ->
if | byte == 45 -> (Negative, rest) -- '-'
| byte == 43 -> (Positive, rest) -- '+'
| otherwise -> (Positive, bytes)
-- | The digits after a decimal point, and the bytes after them; a point
-- without digits after it is not part of the number.
splitFraction :: ByteString -> (ByteString, ByteString)
splitFraction bytes = case B.uncons bytes of
Nothing -> (B.empty, bytes)
Just (byte, rest) ->
let (fraction, remaining) = B.span isDigit rest
in if byte == 46 && not (B.null fraction) -- '.'
then (fraction, remaining)
else (B.empty, bytes)
-- | Keeps 'maxBound', the mark of an exponent too long to read, as it is.
minusDigits :: Int -> Int -> Int
minusDigits written fractionDigits =
if written == maxBound then maxBound else written - fractionDigits
isDigit :: Word8 -> Bool
isDigit byte = byte >= 48 && byte <= 57
appendDigit :: Word64 -> Word8 -> Word64
appendDigit accumulated digit = accumulated * 10 + Word8.toWord64 (digit - 48)
appendDigitInt :: Int -> Word8 -> Int
appendDigitInt accumulated digit = accumulated * 10 + Word8.toInt (digit - 48)