packages feed

valiant-0.1.0.0: src/Valiant/Binary/HStore.hs

{-# OPTIONS_GHC -Wno-orphans #-}

-- | Binary codec for the PostgreSQL @hstore@ extension type.
--
-- PG binary format for hstore:
--   4 bytes: number of pairs (Int32)
--   per pair:
--     4 bytes: key length (Int32)
--     N bytes: key data (UTF-8)
--     4 bytes: value length (Int32, -1 for NULL)
--     N bytes: value data (UTF-8)
--
-- The extension must be enabled: @CREATE EXTENSION IF NOT EXISTS hstore@.
-- hstore OID is not fixed — it depends on installation. The OID is
-- typically resolved dynamically via @pg_type@ during @valiant prepare@.
module Valiant.Binary.HStore
  ( PgHStore (..)
  , hstoreFromList
  , hstoreToList
  ) where

import Control.DeepSeq (NFData)
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.Bifunctor (second)
import Data.Bits (shiftL, (.|.))
import Data.Int (Int32)
import Data.Map.Strict (Map)
import Data.Map.Strict qualified as Map
import Data.Text (Text)
import Data.Text.Encoding qualified as T
import GHC.Generics (Generic)
import PgWire.Binary.Types (PgDecode (..), PgEncode (..))
import PgWire.Protocol.Oid (Oid (..))

-- | A PostgreSQL @hstore@ value — a set of key/value string pairs.
-- Values are nullable (a key can map to SQL NULL).
newtype PgHStore = PgHStore
  { unHStore :: Map Text (Maybe Text)
  -- ^ The underlying map. 'Nothing' values represent SQL NULL.
  }
  deriving stock (Show, Eq, Generic)

instance NFData PgHStore

-- | Construct a 'PgHStore' from a list of key/value pairs.
-- All values are non-NULL.
--
-- @
-- hstoreFromList [(\"color\", \"red\"), (\"size\", \"large\")]
-- @
hstoreFromList :: [(Text, Text)] -> PgHStore
hstoreFromList = PgHStore . Map.fromList . map (second Just)

-- | Extract non-NULL key/value pairs as a list.
hstoreToList :: PgHStore -> [(Text, Text)]
hstoreToList (PgHStore m) =
  [(k, v) | (k, Just v) <- Map.toList m]

-- | hstore extension OID is installation-dependent. We use 0 here as a
-- placeholder; the actual OID is resolved at prepare time.
oidHStore :: Oid
oidHStore = Oid 0

instance PgEncode PgHStore where
  pgEncode (PgHStore m) =
    let pairs = Map.toList m
        nPairs = fromIntegral (length pairs) :: Int32
     in LBS.toStrict . B.toLazyByteString $
          B.int32BE nPairs <> foldMap encodePair pairs
    where
      encodePair (k, mv) =
        let kbs = T.encodeUtf8 k
         in B.int32BE (fromIntegral (BS.length kbs))
              <> B.byteString kbs
              <> case mv of
                   Nothing -> B.int32BE (-1)
                   Just v ->
                     let vbs = T.encodeUtf8 v
                      in B.int32BE (fromIntegral (BS.length vbs))
                           <> B.byteString vbs
  {-# INLINE pgEncode #-}
  pgOid _ = oidHStore
  {-# INLINE pgOid #-}

instance PgDecode PgHStore where
  pgDecode bs
    | BS.length bs < 4 = Left "hstore: too short for pair count"
    | otherwise = do
        nPairs <- decodeInt32At bs 0
        decodePairs bs 4 (fromIntegral nPairs :: Int) []
    where
      decodePairs _ _ 0 !acc = Right (PgHStore (Map.fromList (reverse acc)))
      decodePairs b off n !acc
        | off + 4 > BS.length b = Left "hstore: unexpected end reading key length"
        | otherwise = do
            kLen <- decodeInt32At b off
            let kOff = off + 4
                kEnd = kOff + fromIntegral kLen
            if kEnd > BS.length b
              then Left "hstore: unexpected end reading key data"
              else do
                let key = T.decodeUtf8 (BS.take (fromIntegral kLen) (BS.drop kOff b))
                if kEnd + 4 > BS.length b
                  then Left "hstore: unexpected end reading value length"
                  else do
                    vLen <- decodeInt32At b kEnd
                    if vLen == -1
                      then decodePairs b (kEnd + 4) (n - 1) ((key, Nothing) : acc)
                      else do
                        let vOff = kEnd + 4
                            vEnd = vOff + fromIntegral vLen
                        if vEnd > BS.length b
                          then Left "hstore: unexpected end reading value data"
                          else do
                            let val = T.decodeUtf8 (BS.take (fromIntegral vLen) (BS.drop vOff b))
                            decodePairs b vEnd (n - 1) ((key, Just val) : acc)
  {-# INLINE pgDecode #-}

decodeInt32At :: ByteString -> Int -> Either String Int32
decodeInt32At bs off
  | off + 4 > BS.length bs = Left "hstore: int32 out of bounds"
  | 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)