valiant-0.1.0.0: src/Valiant/Binary/Array.hs
{-# OPTIONS_GHC -Wno-orphans #-}
module Valiant.Binary.Array
( pgEncodeArray
, pgDecodeArray
) where
import Data.Bits (shiftL, (.|.))
import Data.ByteString (ByteString)
import Data.ByteString qualified as BS
import Data.ByteString.Builder qualified as B
import Data.ByteString.Lazy qualified as LBS
import Data.Int (Int32)
import Data.Vector (Vector)
import Data.Vector qualified as V
import Data.Word (Word32)
import PgWire.Binary.Types (PgDecode (..), PgEncode (..))
import PgWire.Protocol.Oid (Oid (..))
-- | Encode a Vector as a PostgreSQL one-dimensional array in binary format.
--
-- PG binary array format:
-- 4 bytes: number of dimensions (1 for 1-D)
-- 4 bytes: has-null flag (0 or 1)
-- 4 bytes: element OID
-- -- per dimension:
-- 4 bytes: dimension size (number of elements)
-- 4 bytes: lower bound (1 for standard arrays)
-- -- per element:
-- 4 bytes: element length (-1 for NULL)
-- N bytes: element data
pgEncodeArray :: (PgEncode a) => Oid -> Vector a -> ByteString
pgEncodeArray (Oid elemOid) elems =
LBS.toStrict . B.toLazyByteString $
B.int32BE 1 -- 1 dimension
<> B.int32BE 0 -- no nulls
<> B.word32BE elemOid -- element OID
<> B.int32BE (fromIntegral (V.length elems)) -- dimension size
<> B.int32BE 1 -- lower bound (1-based)
<> V.foldl' (\acc a -> acc <> encodeElement a) mempty elems
where
encodeElement a =
let bs = pgEncode a
in B.int32BE (fromIntegral (BS.length bs)) <> B.byteString bs
-- | Decode a PostgreSQL one-dimensional binary array to a @Vector@.
--
-- Limitations:
--
-- * Only one-dimensional arrays are supported. Multi-dimensional arrays
-- produce an error.
-- * NULL elements are not supported — PostgreSQL arrays containing NULLs
-- will fail with @\"NULL elements not supported in Vector decode\"@.
-- Use @Vector (Maybe a)@ with a custom decoder if you need nullable
-- array elements.
pgDecodeArray :: (PgDecode a) => ByteString -> Either String (Vector a)
pgDecodeArray bs
| BS.length bs < 12 = Left "array: header too short (need at least 12 bytes)"
| otherwise = do
ndim <- decodeInt32At bs 0
_hasNull <- decodeInt32At bs 4
_elemOid <- decodeWord32At bs 8
case ndim of
0 -> Right V.empty
1 -> do
when (BS.length bs < 20) $ Left "array: 1-D header too short (need at least 20 bytes)"
nelems <- decodeInt32At bs 12
_lbound <- decodeInt32At bs 16
decodeElements bs 20 (fromIntegral nelems)
_ -> Left $ "array: only 1-D arrays supported, got " <> show ndim <> " dimensions"
decodeElements :: (PgDecode a) => ByteString -> Int -> Int -> Either String (Vector a)
decodeElements bs offset count = go offset count []
where
go _off 0 acc = Right (V.fromList (reverse acc))
go off n acc
| off + 4 > BS.length bs = Left "array: unexpected end of data reading element length"
| otherwise = do
elemLen <- decodeInt32At bs off
if elemLen == -1
then Left "array: NULL elements not supported in Vector decode"
else do
let dataOff = off + 4
dataEnd = dataOff + fromIntegral elemLen
when (dataEnd > BS.length bs) $
Left "array: unexpected end of data reading element"
let elemBs = BS.take (fromIntegral elemLen) (BS.drop dataOff bs)
val <- pgDecode elemBs
go dataEnd (n - 1) (val : acc)
-- Helpers -------------------------------------------------------------------
decodeInt32At :: ByteString -> Int -> Either String Int32
decodeInt32At bs off
| off + 4 > BS.length bs = Left "array: unexpected end of data"
| otherwise =
let b0 = fromIntegral (BS.index bs off) :: Int32
b1 = fromIntegral (BS.index bs (off + 1)) :: Int32
b2 = fromIntegral (BS.index bs (off + 2)) :: Int32
b3 = fromIntegral (BS.index bs (off + 3)) :: Int32
in Right (b0 `shiftL` 24 .|. b1 `shiftL` 16 .|. b2 `shiftL` 8 .|. b3)
decodeWord32At :: ByteString -> Int -> Either String Word32
decodeWord32At bs off = fromIntegral <$> decodeInt32At bs off
when :: Bool -> Either String () -> Either String ()
when True e = e
when False _ = Right ()