packages feed

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

-- | The native (pure-Haskell) @pqi@ adapter.
--
-- 'adapter' bundles the three functions that produce a 'Pqi.Connection'
-- whose fields are closures over the underlying native 'Connection.Connection'
-- (which speaks the PostgreSQL wire protocol directly). 'Pqi.Result' and
-- 'Pqi.Cancel' values are constructed the same way, in "Pqi.Native.Types".
module Pqi.Native
  ( adapter,
  )
where

import qualified Data.ByteString as ByteString
import qualified Data.ByteString.Char8 as ByteString.Char8
import qualified Data.Map.Strict as Map
import qualified 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 (..), mkCancel, mkResult)
import qualified Pqi.Native.UnescapeBytea as UnescapeBytea
import System.Posix.Types (Fd)

-- | The native adapter.
adapter :: Pqi.Adapter
adapter =
  Pqi.Adapter
    { Pqi.name = "pqi-native",
      Pqi.connectdb = \conninfo -> mkConnection <$> Connection.establish conninfo,
      Pqi.connectStart = \conninfo -> mkConnection <$> Connection.establish conninfo,
      Pqi.newNullConnection = mkConnection <$> Connection.nullConnection,
      Pqi.unescapeBytea = \input -> pure (Just (UnescapeBytea.unescapeBytea input))
    }

-- | Build a 'Pqi.Connection' whose fields close over the given native
-- connection.
mkConnection :: Connection -> Pqi.Connection
mkConnection connection =
  Pqi.Connection
    { Pqi.connectPoll = pure Pqi.PollingOk,
      Pqi.isNullConnection = connection.isNull,
      Pqi.finish = readIORef connection.transport >>= Transport.close,
      Pqi.reset = Connection.reconnect connection,
      Pqi.resetStart = Connection.reconnect connection $> True,
      Pqi.resetPoll = pure Pqi.PollingOk,
      Pqi.db = pure (Just connection.info.database),
      Pqi.user = pure (Just connection.info.user),
      Pqi.pass = pure (Just connection.info.password),
      Pqi.host = pure (Just connection.info.host),
      Pqi.port = pure (Just (ByteString.Char8.pack (show connection.info.port))),
      Pqi.options = pure (Just ""),
      Pqi.status = readIORef connection.connStatus,
      Pqi.transactionStatus = transactionStatusOf <$> readIORef connection.txStatus,
      Pqi.parameterStatus = \name -> Map.lookup name <$> readIORef connection.parameters,
      Pqi.protocolVersion = pure 3,
      Pqi.serverVersion =
        maybe 0 parseServerVersion . Map.lookup "server_version" <$> readIORef connection.parameters,
      Pqi.errorMessage = readIORef connection.lastError,
      Pqi.socket = do
        transport <- readIORef connection.transport
        fd <- Transport.socketFd transport
        pure (Just (fromIntegral fd :: Fd)),
      Pqi.backendPID = maybe 0 fst <$> readIORef connection.backendKey,
      Pqi.connectionNeedsPassword = pure False,
      Pqi.connectionUsedPassword = pure (not (ByteString.null connection.info.password)),
      Pqi.exec = \sql -> fmap mkResult <$> Query.exec connection sql,
      Pqi.execParams = \sql params resultFormat ->
        fmap mkResult <$> Query.execParams connection sql params resultFormat,
      Pqi.prepare = \name sql parameterTypes ->
        fmap mkResult <$> Query.prepare connection name sql parameterTypes,
      Pqi.execPrepared = \name params resultFormat ->
        fmap mkResult <$> Query.execPrepared connection name params resultFormat,
      Pqi.describePrepared = \name -> fmap mkResult <$> Query.describePrepared connection name,
      Pqi.describePortal = \name -> fmap mkResult <$> Query.describePortal connection name,
      Pqi.escapeStringConn = \value ->
        if isValidUtf8 value
          then pure (Just (ByteString.intercalate "''" (ByteString.split 0x27 value)))
          else pure Nothing,
      Pqi.escapeByteaConn = \value -> pure (Just ("\\x" <> hexEncode value)),
      Pqi.escapeIdentifier = \value ->
        if isValidUtf8 value
          then pure (Just ("\"" <> ByteString.intercalate "\"\"" (ByteString.split 0x22 value) <> "\""))
          else pure Nothing,
      Pqi.sendQuery = Query.sendQuery connection,
      Pqi.sendQueryParams = \sql params resultFormat -> Query.sendQueryParams connection sql params resultFormat,
      Pqi.sendPrepare = \name sql parameterTypes -> Query.sendPrepare connection name sql parameterTypes,
      Pqi.sendQueryPrepared = \name params resultFormat -> Query.sendQueryPrepared connection name params resultFormat,
      Pqi.sendDescribePrepared = Query.sendDescribePrepared connection,
      Pqi.sendDescribePortal = Query.sendDescribePortal connection,
      Pqi.getResult = fmap mkResult <$> Query.getNextResult connection,
      Pqi.consumeInput = pure True,
      Pqi.isBusy = pure False,
      Pqi.setnonblocking = \flag -> writeIORef connection.nonblocking flag $> True,
      Pqi.isnonblocking = readIORef connection.nonblocking,
      Pqi.setSingleRowMode = do
        pending <- readIORef connection.asyncPending
        if pending
          then writeIORef connection.singleRowMode True $> True
          else pure False,
      Pqi.flush = pure Pqi.FlushOk,
      Pqi.pipelineStatus = readIORef connection.pipelineStatus,
      Pqi.enterPipelineMode = writeIORef connection.pipelineStatus Pqi.PipelineOn $> True,
      Pqi.exitPipelineMode = do
        pending <- readIORef connection.asyncPending
        if pending
          then pure False
          else writeIORef connection.pipelineStatus Pqi.PipelineOff $> True,
      Pqi.pipelineSync = do
        Connection.sendMessage connection syncMessage
        modifyIORef' connection.pendingSyncs (+ 1)
        writeIORef connection.asyncPending True
        pure True,
      Pqi.sendFlushRequest = Connection.sendMessage connection flushMessage $> True,
      Pqi.getCancel = do
        key <- readIORef connection.backendKey
        pure
          $ fmap
            ( \(pid, secret) ->
                mkCancel
                  NativeCancel
                    { host = connection.info.host,
                      port = connection.info.port,
                      pid,
                      secret,
                      asyncPendingRef = connection.asyncPending,
                      pipelineStatusRef = connection.pipelineStatus,
                      pendingCommandsRef = connection.pendingCommands
                    }
            )
            key,
      Pqi.notifies = popFirst connection.pendingNotifications,
      Pqi.disableNoticeReporting = writeIORef connection.noticeReporting False,
      Pqi.enableNoticeReporting = writeIORef connection.noticeReporting True,
      Pqi.getNotice = popFirst connection.notices,
      Pqi.putCopyData = \payload -> Connection.sendMessage connection (copyDataMessage payload) $> Pqi.CopyInOk,
      Pqi.putCopyEnd = \reason -> do
        Connection.sendMessage connection (maybe copyDoneMessage copyFailMessage reason)
        writeIORef connection.asyncPending True
        pure Pqi.CopyInOk,
      Pqi.getCopyData = getCopyData connection,
      Pqi.loCreat = LargeObject.loCreat connection,
      Pqi.loCreate = LargeObject.loCreate connection,
      Pqi.loImport = LargeObject.loImport connection,
      Pqi.loImportWithOid = LargeObject.loImportWithOid connection,
      Pqi.loExport = LargeObject.loExport connection,
      Pqi.loOpen = LargeObject.loOpen connection,
      Pqi.loWrite = LargeObject.loWrite connection,
      Pqi.loRead = LargeObject.loRead connection,
      Pqi.loSeek = LargeObject.loSeek connection,
      Pqi.loTell = LargeObject.loTell connection,
      Pqi.loTruncate = LargeObject.loTruncate connection,
      Pqi.loClose = LargeObject.loClose connection,
      Pqi.loUnlink = LargeObject.loUnlink connection,
      Pqi.clientEncoding =
        fromMaybe "SQL_ASCII" . Map.lookup "client_encoding" <$> readIORef connection.parameters,
      Pqi.setClientEncoding = \encoding -> do
        result <- Query.exec connection ("SET client_encoding TO '" <> encoding <> "'")
        pure (maybe False (\value -> value.status /= Pqi.FatalError) result),
      Pqi.setErrorVerbosity = \verbosity -> do
        previous <- readIORef connection.errorVerbosity
        writeIORef connection.errorVerbosity verbosity
        pure previous
    }

-- | Receive data on a @COPY TO STDOUT@ connection, as 'Pqi.getCopyData'. The
-- native adapter has no non-blocking transport, so the @Bool@ argument is
-- ignored; it always reads until a full chunk (or the end of the copy) is
-- available.
getCopyData :: Connection -> Bool -> IO Pqi.CopyOutResult
getCopyData connection nonBlocking = do
  message <- Connection.nextMessage connection
  case message of
    CopyData payload -> pure (Pqi.CopyOutRow payload)
    CopyDone -> do
      writeIORef connection.asyncPending True
      pure Pqi.CopyOutDone
    CommandComplete _ -> drainToReady connection $> Pqi.CopyOutDone
    ErrorResponse _ -> drainToReady connection $> Pqi.CopyOutError
    ReadyForQuery txState -> writeIORef connection.txStatus txState $> Pqi.CopyOutDone
    _ -> getCopyData connection nonBlocking

-- | 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 -> Pqi.TransactionStatus
transactionStatusOf = \case
  0x49 -> Pqi.TransIdle -- 'I'
  0x54 -> Pqi.TransInTrans -- 'T'
  0x45 -> Pqi.TransInError -- 'E'
  _ -> Pqi.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')