packages feed

pqi-native-0.0.1.0: src/library/Pqi/Native.hs

{-# LANGUAGE UndecidableInstances #-}
{-# OPTIONS_GHC -Wno-orphans #-}

-- | The native (pure-Haskell) @pqi@ adapter.
--
-- 'Connection' speaks the PostgreSQL wire protocol directly.
-- Provides the 'IsConnection' instance.
module Pqi.Native
  ( Connection,
  )
where

import qualified Data.ByteString as ByteString
import qualified Data.ByteString.Char8 as ByteString.Char8
import qualified Data.Map.Strict as Map
import Pqi
import Pqi.Native.Connection (Connection (..))
import qualified Pqi.Native.Connection as Connection
import qualified Pqi.Native.LargeObject as LargeObject
import Pqi.Native.Prelude
import qualified Pqi.Native.Query as Query
import qualified Pqi.Native.Transport as Transport
import Pqi.Native.Transport.Message
  ( BackendMessage (..),
    copyDataMessage,
    copyDoneMessage,
    copyFailMessage,
    flushMessage,
    syncMessage,
  )
import Pqi.Native.Types (NativeCancel (..), NativeResult (..))
import System.Posix.Types (Fd)

instance IsConnection Connection where
  type ResultOf Connection = NativeResult
  type CancelOf Connection = NativeCancel

  connectdb = Connection.establish
  connectStart = Connection.establish
  connectPoll _ = pure PollingOk
  newNullConnection = Connection.nullConnection
  isNullConnection connection = connection.isNull
  finish connection = readIORef connection.transport >>= Transport.close
  reset = Connection.reconnect
  resetStart connection = Connection.reconnect connection $> True
  resetPoll _ = pure PollingOk
  db connection = pure (Just connection.info.database)
  user connection = pure (Just connection.info.user)
  pass connection = pure (Just connection.info.password)
  host connection = pure (Just connection.info.host)
  port connection = pure (Just (ByteString.Char8.pack (show connection.info.port)))
  options _ = pure (Just "")
  status connection = readIORef connection.connStatus
  transactionStatus connection = transactionStatusOf <$> readIORef connection.txStatus
  parameterStatus connection name = Map.lookup name <$> readIORef connection.parameters
  protocolVersion _ = pure 3
  serverVersion connection =
    maybe 0 parseServerVersion . Map.lookup "server_version" <$> readIORef connection.parameters
  errorMessage connection = readIORef connection.lastError
  socket connection = do
    transport <- readIORef connection.transport
    fd <- Transport.socketFd transport
    pure (Just (fromIntegral fd :: Fd))
  backendPID connection = maybe 0 fst <$> readIORef connection.backendKey
  connectionNeedsPassword _ = pure False
  connectionUsedPassword connection = pure (not (ByteString.null connection.info.password))

  exec connection sql = Query.exec connection sql
  execParams connection sql params resultFormat =
    Query.execParams connection sql params resultFormat
  prepare connection name sql parameterTypes =
    Query.prepare connection name sql parameterTypes
  execPrepared connection name params resultFormat =
    Query.execPrepared connection name params resultFormat
  describePrepared connection name = Query.describePrepared connection name
  describePortal connection name = Query.describePortal connection name

  escapeStringConn _ value
    | isValidUtf8 value =
        pure (Just (ByteString.intercalate "''" (ByteString.split 0x27 value)))
    | otherwise = pure Nothing
  escapeByteaConn _ value =
    pure (Just ("\\x" <> hexEncode value))
  escapeIdentifier _ value
    | isValidUtf8 value =
        pure (Just ("\"" <> ByteString.intercalate "\"\"" (ByteString.split 0x22 value) <> "\""))
    | otherwise = pure Nothing

  sendQuery = Query.sendQuery
  sendQueryParams = Query.sendQueryParams
  sendPrepare = Query.sendPrepare
  sendQueryPrepared = Query.sendQueryPrepared
  sendDescribePrepared = Query.sendDescribePrepared
  sendDescribePortal = Query.sendDescribePortal
  getResult connection = Query.getNextResult connection
  consumeInput _ = pure True
  isBusy _ = pure False
  setnonblocking connection flag = writeIORef connection.nonblocking flag $> True
  isnonblocking connection = readIORef connection.nonblocking
  setSingleRowMode connection = do
    pending <- readIORef connection.asyncPending
    if pending
      then writeIORef connection.singleRowMode True $> True
      else pure False
  flush _ = pure FlushOk

  pipelineStatus connection = readIORef connection.pipelineStatus
  enterPipelineMode connection = writeIORef connection.pipelineStatus PipelineOn $> True
  exitPipelineMode connection = do
    pending <- readIORef connection.asyncPending
    if pending
      then pure False
      else writeIORef connection.pipelineStatus PipelineOff $> True
  pipelineSync connection = do
    Connection.sendMessage connection syncMessage
    modifyIORef' connection.pendingSyncs (+ 1)
    writeIORef connection.asyncPending True
    pure True
  sendFlushRequest connection = Connection.sendMessage connection flushMessage $> True

  getCancel connection = do
    key <- readIORef connection.backendKey
    pure
      $ fmap
        ( \(pid, secret) ->
            NativeCancel
              { host = connection.info.host,
                port = connection.info.port,
                pid,
                secret,
                asyncPendingRef = connection.asyncPending,
                pipelineStatusRef = connection.pipelineStatus,
                pendingCommandsRef = connection.pendingCommands
              }
        )
        key

  notifies connection = popFirst connection.pendingNotifications
  disableNoticeReporting connection = writeIORef connection.noticeReporting False
  enableNoticeReporting connection = writeIORef connection.noticeReporting True
  getNotice connection = popFirst connection.notices

  putCopyData connection payload = Connection.sendMessage connection (copyDataMessage payload) $> CopyInOk
  putCopyEnd connection reason = do
    Connection.sendMessage connection (maybe copyDoneMessage copyFailMessage reason)
    writeIORef connection.asyncPending True
    pure CopyInOk
  getCopyData connection _nonBlocking = do
    message <- Connection.nextMessage connection
    case message of
      CopyData payload -> pure (CopyOutRow payload)
      CopyDone -> do
        writeIORef connection.asyncPending True
        pure CopyOutDone
      CommandComplete _ -> drainToReady connection $> CopyOutDone
      ErrorResponse _ -> drainToReady connection $> CopyOutError
      ReadyForQuery txState -> writeIORef connection.txStatus txState $> CopyOutDone
      _ -> getCopyData connection _nonBlocking

  loCreat = LargeObject.loCreat
  loCreate = LargeObject.loCreate
  loImport = LargeObject.loImport
  loImportWithOid = LargeObject.loImportWithOid
  loExport = LargeObject.loExport
  loOpen = LargeObject.loOpen
  loWrite = LargeObject.loWrite
  loRead = LargeObject.loRead
  loSeek = LargeObject.loSeek
  loTell = LargeObject.loTell
  loTruncate = LargeObject.loTruncate
  loClose = LargeObject.loClose
  loUnlink = LargeObject.loUnlink

  clientEncoding connection =
    fromMaybe "SQL_ASCII" . Map.lookup "client_encoding" <$> readIORef connection.parameters
  setClientEncoding connection encoding = do
    result <- Query.exec connection ("SET client_encoding TO '" <> encoding <> "'")
    pure (maybe False (\value -> value.status /= FatalError) result)
  setErrorVerbosity connection verbosity = do
    previous <- readIORef connection.errorVerbosity
    writeIORef connection.errorVerbosity verbosity
    pure previous

-- | Read messages until @ReadyForQuery@, recording the transaction status.
drainToReady :: Connection -> IO ()
drainToReady connection = do
  message <- Connection.nextMessage connection
  case message of
    ReadyForQuery txState -> writeIORef connection.txStatus txState
    _ -> drainToReady connection

-- | Pop the oldest element of a list stored newest-first.
popFirst :: IORef [a] -> IO (Maybe a)
popFirst ref =
  atomicModifyIORef' ref \xs -> case reverse xs of
    [] -> ([], Nothing)
    oldest : rest -> (reverse rest, Just oldest)

transactionStatusOf :: Word8 -> TransactionStatus
transactionStatusOf = \case
  0x49 -> TransIdle -- 'I'
  0x54 -> TransInTrans -- 'T'
  0x45 -> TransInError -- 'E'
  _ -> TransUnknown

-- | Parse the @server_version@ parameter into libpq's @MMmmpp@ integer form
-- (e.g. @\"17.2\"@ -> @170002@, @\"9.6.3\"@ -> @90603@).
parseServerVersion :: ByteString -> Int
parseServerVersion raw =
  case ByteString.Char8.readInt raw of
    Nothing -> 0
    Just (major, rest)
      | major >= 10 -> major * 10000 + nextInt rest
      | otherwise ->
          let minor = nextInt rest
              patch = nextInt (dropInt rest)
           in major * 10000 + minor * 100 + patch
  where
    nextInt bs = case ByteString.Char8.uncons bs of
      Just ('.', remainder) -> maybe 0 fst (ByteString.Char8.readInt remainder)
      _ -> 0
    dropInt bs = case ByteString.Char8.uncons bs of
      Just ('.', remainder) -> case ByteString.Char8.readInt remainder of
        Just (_, leftover) -> leftover
        Nothing -> remainder
      _ -> bs

isValidUtf8 :: ByteString -> Bool
isValidUtf8 = go . ByteString.unpack
  where
    go [] = True
    go (b : bs)
      | b < 0x80 = go bs
      | b < 0xc2 = False
      | b < 0xe0 = cont bs 1
      | b < 0xf0 = cont bs 2
      | b < 0xf5 = cont bs 3
      | otherwise = False
    cont bs (0 :: Int) = go bs
    cont [] _ = False
    cont (b : bs) n
      | b .&. 0xc0 == 0x80 = cont bs (n - 1)
      | otherwise = False

hexEncode :: ByteString -> ByteString
hexEncode = ByteString.Char8.pack . concatMap toHex . ByteString.unpack
  where
    toHex byte = [digit (byte `div` 16), digit (byte `mod` 16)]
    digit n
      | n < 10 = toEnum (fromIntegral n + fromEnum '0')
      | otherwise = toEnum (fromIntegral n - 10 + fromEnum 'a')