valiant-0.1.0.0: src/Valiant/Binary/Decode.hs
{-# OPTIONS_GHC -Wno-orphans #-}
module Valiant.Binary.Decode
( refine
, refineWith
) where
import Data.Bits (shiftL, (.|.))
import Data.ByteString (ByteString)
import Data.ByteString qualified as BS
import Data.ByteString.Unsafe qualified as BU
import Data.Int (Int16, Int32, Int64)
import Data.Text (Text)
import Data.Text.Encoding qualified as TE
import Data.Time
( Day
, LocalTime (..)
, TimeOfDay
, TimeZone (..)
, UTCTime (..)
, ZonedTime (..)
, fromGregorian
, minutesToTimeZone
, picosecondsToDiffTime
, timeToTimeOfDay
, utcToZonedTime
)
import Data.Time.Calendar (addDays)
import Data.Word (Word32, Word64)
import GHC.Float (castWord32ToFloat, castWord64ToDouble)
import PgWire.Binary.Types (PgDecode (..))
-- PG epoch offset from Unix epoch in seconds
pgEpochOffsetSeconds :: Int64
pgEpochOffsetSeconds = 946684800
{-# INLINE pgEpochOffsetSeconds #-}
pgEpochDay :: Day
pgEpochDay = fromGregorian 2000 1 1
{-# INLINE pgEpochDay #-}
-- Refinement --------------------------------------------------------------
-- | Apply a refinement check after binary decode. Useful for types whose
-- binary representation is the same as a primitive but which carry
-- additional invariants (e.g. validated email, UUID-formatted Text,
-- positive Int32, enum strings).
refine :: PgDecode a => (a -> Either String b) -> ByteString -> Either String b
refine check bs = pgDecode bs >>= check
{-# INLINE refine #-}
-- | Like 'refine' but prepends a context prefix to error messages.
refineWith :: PgDecode a => String -> (a -> Either String b) -> ByteString -> Either String b
refineWith ctx check bs = case pgDecode bs of
Left e -> Left (ctx <> ": " <> e)
Right a -> case check a of
Left e -> Left (ctx <> ": " <> e)
Right b -> Right b
{-# INLINE refineWith #-}
-- Helpers -----------------------------------------------------------------
decodeInt16BE :: ByteString -> Either String Int16
decodeInt16BE bs
| BS.length bs /= 2 = Left $ "int16: expected 2 bytes, got " <> show (BS.length bs)
| otherwise =
let b0 = fromIntegral (BU.unsafeIndex bs 0) :: Int16
b1 = fromIntegral (BU.unsafeIndex bs 1) :: Int16
in Right (b0 `shiftL` 8 .|. b1)
{-# INLINE decodeInt16BE #-}
decodeInt32BE :: ByteString -> Either String Int32
decodeInt32BE bs
| BS.length bs /= 4 = Left $ "int32: expected 4 bytes, got " <> show (BS.length bs)
| otherwise =
let b0 = fromIntegral (BU.unsafeIndex bs 0) :: Int32
b1 = fromIntegral (BU.unsafeIndex bs 1) :: Int32
b2 = fromIntegral (BU.unsafeIndex bs 2) :: Int32
b3 = fromIntegral (BU.unsafeIndex bs 3) :: Int32
in Right (b0 `shiftL` 24 .|. b1 `shiftL` 16 .|. b2 `shiftL` 8 .|. b3)
{-# INLINE decodeInt32BE #-}
decodeInt64BE :: ByteString -> Either String Int64
decodeInt64BE bs
| BS.length bs /= 8 = Left $ "int64: expected 8 bytes, got " <> show (BS.length bs)
| otherwise =
let !b0 = fromIntegral (BU.unsafeIndex bs 0) :: Int64
!b1 = fromIntegral (BU.unsafeIndex bs 1) :: Int64
!b2 = fromIntegral (BU.unsafeIndex bs 2) :: Int64
!b3 = fromIntegral (BU.unsafeIndex bs 3) :: Int64
!b4 = fromIntegral (BU.unsafeIndex bs 4) :: Int64
!b5 = fromIntegral (BU.unsafeIndex bs 5) :: Int64
!b6 = fromIntegral (BU.unsafeIndex bs 6) :: Int64
!b7 = fromIntegral (BU.unsafeIndex bs 7) :: Int64
in Right (b0 `shiftL` 56 .|. b1 `shiftL` 48 .|. b2 `shiftL` 40 .|. b3 `shiftL` 32
.|. b4 `shiftL` 24 .|. b5 `shiftL` 16 .|. b6 `shiftL` 8 .|. b7)
{-# INLINE decodeInt64BE #-}
decodeWord32BE :: ByteString -> Either String Word32
decodeWord32BE bs = fromIntegral <$> decodeInt32BE bs
{-# INLINE decodeWord32BE #-}
decodeWord64BE :: ByteString -> Either String Word64
decodeWord64BE bs = fromIntegral <$> decodeInt64BE bs
{-# INLINE decodeWord64BE #-}
-- Instances ---------------------------------------------------------------
instance PgDecode Bool where
pgDecode bs
| BS.length bs /= 1 = Left "bool: expected 1 byte"
| otherwise = Right (BU.unsafeIndex bs 0 /= 0)
{-# INLINE pgDecode #-}
instance PgDecode Int16 where
pgDecode = decodeInt16BE
{-# INLINE pgDecode #-}
instance PgDecode Int32 where
pgDecode = decodeInt32BE
{-# INLINE pgDecode #-}
instance PgDecode Int64 where
pgDecode = decodeInt64BE
{-# INLINE pgDecode #-}
instance PgDecode Float where
pgDecode bs = castWord32ToFloat <$> decodeWord32BE bs
{-# INLINE pgDecode #-}
instance PgDecode Double where
pgDecode bs = castWord64ToDouble <$> decodeWord64BE bs
{-# INLINE pgDecode #-}
instance PgDecode Text where
-- PostgreSQL guarantees valid UTF-8 for text/varchar columns, so we
-- use decodeUtf8 (no validation) instead of decodeUtf8' (validates
-- every byte). This eliminates ~20-30ns per text column on ASCII data.
pgDecode bs = Right (TE.decodeUtf8 bs)
{-# INLINE pgDecode #-}
instance PgDecode ByteString where
pgDecode = Right
{-# INLINE pgDecode #-}
instance PgDecode UTCTime where
pgDecode bs = do
pgMicros <- decodeInt64BE bs
let totalMicros = pgMicros + pgEpochOffsetSeconds * 1000000
(days, remainMicros) = totalMicros `divMod` 86400000000
unixEpoch = fromGregorian 1970 1 1
day = addDays (fromIntegral days) unixEpoch
picos = fromIntegral remainMicros * 1000000
Right (UTCTime day (picosecondsToDiffTime picos))
{-# INLINE pgDecode #-}
instance PgDecode Day where
pgDecode bs = do
days <- decodeInt32BE bs
Right (addDays (fromIntegral days) pgEpochDay)
{-# INLINE pgDecode #-}
instance PgDecode TimeOfDay where
pgDecode bs = do
micros <- decodeInt64BE bs
let picos = fromIntegral micros * 1000000
Right (timeToTimeOfDay (picosecondsToDiffTime picos))
{-# INLINE pgDecode #-}
instance PgDecode LocalTime where
pgDecode bs = do
totalMicros <- decodeInt64BE bs
let (days, remainMicros) = totalMicros `divMod` 86400000000
day = addDays (fromIntegral days) pgEpochDay
picos = fromIntegral remainMicros * 1000000
tod = timeToTimeOfDay (picosecondsToDiffTime picos)
Right (LocalTime day tod)
{-# INLINE pgDecode #-}
-- | Decode @timestamptz@ as 'ZonedTime'. PostgreSQL stores timestamptz
-- as UTC microseconds (same as UTCTime). We decode to UTC and tag with
-- the UTC timezone. To get the original timezone, use application-level
-- conversion.
instance PgDecode ZonedTime where
pgDecode bs = do
utc <- pgDecode bs
Right (utcToZonedTime (minutesToTimeZone 0) utc)
{-# INLINE pgDecode #-}
-- | Decode @timetz@ as @(TimeOfDay, TimeZone)@. Binary format:
-- 8 bytes (microseconds) + 4 bytes (UTC offset in seconds, negated).
instance PgDecode (TimeOfDay, TimeZone) where
pgDecode bs
| BS.length bs /= 12 = Left $ "timetz: expected 12 bytes, got " <> show (BS.length bs)
| otherwise = do
micros <- decodeInt64BE (BS.take 8 bs)
offsetRaw <- decodeInt32BE (BS.drop 8 bs)
let picos = fromIntegral micros * 1000000
tod = timeToTimeOfDay (picosecondsToDiffTime picos)
-- PG stores offset as seconds west of UTC (negated)
tzMins = negate (fromIntegral offsetRaw) `div` 60 :: Int
Right (tod, minutesToTimeZone tzMins)
{-# INLINE pgDecode #-}