packages feed

postgresql-types-0.1.1: src/library/PostgresqlTypes/Hstore.hs

module PostgresqlTypes.Hstore
  ( Hstore,

    -- * Accessors
    toMap,

    -- * Constructors
    refineFromMap,
    normalizeFromMap,
  )
where

import qualified Data.Attoparsec.Text as Attoparsec
import qualified Data.ByteString as ByteString
import qualified Data.Map.Strict as Map
import qualified Data.Text as Text
import qualified Data.Text.Encoding as Text.Encoding
import PostgresqlTypes.Algebra
import PostgresqlTypes.Prelude hiding (hGetContents)
import PostgresqlTypes.Via
import qualified PtrPeeker
import qualified PtrPoker.Write as Write
import qualified Test.QuickCheck as QuickCheck
import qualified TextBuilder

-- | PostgreSQL @hstore@ type. Key-value store where keys are text and values are optional text.
--
-- Hstore is a key-value store where both keys and values are text strings.
-- Values can be NULL (represented as Nothing in Haskell).
-- Keys must be unique and cannot be NULL.
--
-- [PostgreSQL docs](https://www.postgresql.org/docs/18/hstore.html).
newtype Hstore = Hstore (Map.Map Text (Maybe Text))
  deriving newtype (Eq, Ord, Hashable)
  deriving (Show, Read, IsString) via (ViaIsScalar Hstore)

instance Arbitrary Hstore where
  arbitrary = do
    -- Generate a list of key-value pairs
    pairs <- QuickCheck.listOf do
      key <-
        Text.pack
          <$> QuickCheck.listOf1
            ( QuickCheck.suchThat
                arbitrary
                ( \c ->
                    c /= '\NUL'
                      && c /= '='
                      && c /= '>'
                      && c /= '"'
                      && c /= '\\'
                      && c /= ' '
                      && c /= ','
                      && c /= '\n'
                      && c /= '\r'
                      && c /= '\t'
                )
            )
      value <-
        QuickCheck.oneof
          [ pure Nothing,
            Just . Text.pack <$> QuickCheck.listOf (QuickCheck.suchThat arbitrary (\c -> c /= '\NUL'))
          ]
      pure (key, value)
    pure (Hstore (Map.fromList pairs))
  shrink =
    QuickCheck.shrinkMapBy
      (Hstore . Map.fromList)
      (Map.toList . coerce)
      ( QuickCheck.shrinkList
          ( \(key, value) -> do
              shrunkenKey <- QuickCheck.shrinkMap Text.pack Text.unpack key
              shrunkenValue <- case value of
                Nothing -> [Nothing]
                Just v -> Just <$> QuickCheck.shrinkMap Text.pack Text.unpack v
              pure (shrunkenKey, shrunkenValue)
          )
      )

instance IsScalar Hstore where
  schemaName = Tagged Nothing
  typeName = Tagged "hstore"
  baseOid = Tagged Nothing
  arrayOid = Tagged Nothing
  typeParams = Tagged []
  binaryEncoder (Hstore m) = do
    -- Binary format:
    -- 4 bytes: number of key-value pairs (int32)
    -- For each pair:
    --   4 bytes: key length (int32)
    --   N bytes: key data (UTF-8)
    --   4 bytes: value length (int32), -1 for NULL
    --   M bytes: value data (UTF-8) if not NULL
    Write.bInt32 (fromIntegral (Map.size m))
      <> foldMap encodePair (Map.toList m)
    where
      encodePair (key, maybeValue) =
        let keyBytes = Text.Encoding.encodeUtf8 key
            keyLen = fromIntegral (ByteString.length keyBytes)
         in Write.bInt32 keyLen
              <> Write.byteString keyBytes
              <> case maybeValue of
                Nothing -> Write.bInt32 (-1)
                Just value ->
                  let valueBytes = Text.Encoding.encodeUtf8 value
                      valueLen = fromIntegral (ByteString.length valueBytes)
                   in Write.bInt32 valueLen <> Write.byteString valueBytes

  binaryDecoder = runExceptT do
    pairCount <- lift $ PtrPeeker.fixed PtrPeeker.beSignedInt4
    pairs <- replicateM (fromIntegral pairCount) decodePair
    pure (Hstore (Map.fromList pairs))
    where
      decodePair = do
        keyLen <- lift $ PtrPeeker.fixed PtrPeeker.beSignedInt4
        when (keyLen < 0) do
          throwError (DecodingError ["key-length"] (UnsupportedValueDecodingErrorReason "Key length must be non-negative" (TextBuilder.toText (TextBuilder.decimal keyLen))))
        keyBytes <- lift $ PtrPeeker.forceSize (fromIntegral keyLen) PtrPeeker.remainderAsByteString
        case Text.Encoding.decodeUtf8' keyBytes of
          Left e ->
            throwError
              ( DecodingError
                  { location = ["hstore", "key"],
                    reason = ParsingDecodingErrorReason (fromString (show e)) keyBytes
                  }
              )
          Right key -> do
            valueLen <- lift $ PtrPeeker.fixed PtrPeeker.beSignedInt4
            if valueLen == -1
              then pure (key, Nothing)
              else do
                when (valueLen < 0) do
                  throwError (DecodingError ["value-length"] (UnsupportedValueDecodingErrorReason "Value length must be non-negative or -1 for NULL" (TextBuilder.toText (TextBuilder.decimal valueLen))))
                valueBytes <- lift $ PtrPeeker.forceSize (fromIntegral valueLen) PtrPeeker.remainderAsByteString
                case Text.Encoding.decodeUtf8' valueBytes of
                  Left e ->
                    throwError
                      ( DecodingError
                          { location = ["hstore", "value"],
                            reason = ParsingDecodingErrorReason (fromString (show e)) valueBytes
                          }
                      )
                  Right value -> pure (key, Just value)

  textualEncoder (Hstore m) =
    if Map.null m
      then mempty
      else mconcat $ intersperse (TextBuilder.text ", ") $ map encodePair (Map.toList m)
    where
      encodePair (key, maybeValue) =
        TextBuilder.char '"'
          <> TextBuilder.text (escapeText key)
          <> TextBuilder.text "\"=>"
          <> case maybeValue of
            Nothing -> TextBuilder.text "NULL"
            Just value ->
              TextBuilder.char '"'
                <> TextBuilder.text (escapeText value)
                <> TextBuilder.char '"'
      escapeText = Text.concatMap escapeChar
      escapeChar c = case c of
        '\\' -> "\\\\"
        '"' -> "\\\""
        _ -> Text.singleton c

  textualDecoder = do
    pairs <- Attoparsec.sepBy pairParser (Attoparsec.string ", " <|> Attoparsec.string ",")
    pure (Hstore (Map.fromList pairs))
    where
      pairParser = do
        key <- quotedString
        _ <- Attoparsec.string "=>"
        value <- nullValue <|> (Just <$> quotedString)
        pure (key, value)
      quotedString = do
        _ <- Attoparsec.char '"'
        chars <- many (escapedChar <|> normalChar)
        _ <- Attoparsec.char '"'
        pure (Text.pack chars)
      escapedChar = do
        _ <- Attoparsec.char '\\'
        Attoparsec.anyChar
      normalChar = Attoparsec.satisfy (\c -> c /= '"' && c /= '\\')
      nullValue = do
        _ <- Attoparsec.string "NULL"
        pure Nothing

-- * Accessors

-- | Extract the underlying 'Map.Map Text (Maybe Text)'.
toMap :: Hstore -> Map.Map Text (Maybe Text)
toMap (Hstore m) = m

-- * Constructors

-- | Construct from Haskell 'Map.Map Text (Maybe Text)' with validation.
-- Returns 'Nothing' if any key or value contains null characters (not supported by PostgreSQL).
refineFromMap :: Map.Map Text (Maybe Text) -> Maybe Hstore
refineFromMap m =
  if any hasNul (Map.keys m) || any (maybe False hasNul) (Map.elems m)
    then Nothing
    else Just (Hstore m)
  where
    hasNul = Text.elem '\NUL'

-- | Construct from Haskell 'Map.Map Text (Maybe Text)'.
-- Strips null characters to ensure PostgreSQL compatibility.
normalizeFromMap :: Map.Map Text (Maybe Text) -> Hstore
normalizeFromMap m =
  Hstore
    ( Map.fromList
        [ (stripNul k, fmap stripNul v)
        | (k, v) <- Map.toList m
        ]
    )
  where
    stripNul = Text.replace "\NUL" ""