packages feed

pqi-native-1.0.1.14: src/library/Pqi/Native/Query.hs

-- | Command execution: the simple- and extended-query flows, and the
-- materialization of the backend message stream into a 'NativeResult'.
module Pqi.Native.Query
  ( exec,
    execParams,
    prepare,
    execPrepared,
    describePrepared,
    describePortal,
    sendQuery,
    sendQueryParams,
    sendPrepare,
    sendQueryPrepared,
    sendDescribePrepared,
    sendDescribePortal,
    getNextResult,
  )
where

import Control.Exception (catch, mask_)
import qualified Data.Map.Strict as Map
import qualified Data.Sequence as Seq
import qualified Data.Vector.Mutable as MVector
import Pqi (ConnStatus (..), ExecStatus (..), Format (..), PipelineStatus (..))
import Pqi.Native.Connection
import Pqi.Native.Prelude
import Pqi.Native.Transport.Message
import Pqi.Native.Types (NativeResult (..), formatResultError, lostConnectionResult)
import qualified PtrPoker.Write as Poker

-- * Message construction

-- Sync-inclusive variants used by the synchronous exec* functions.

paramsWrite :: ByteString -> [Maybe (Word32, ByteString, Format)] -> Format -> Poker.Write
paramsWrite sql params resultFormat =
  asyncParamsWrite sql params resultFormat <> syncMessage

preparedWrite :: ByteString -> [Maybe (ByteString, Format)] -> Format -> Poker.Write
preparedWrite name params resultFormat =
  asyncPreparedWrite name params resultFormat <> syncMessage

prepareWrite :: ByteString -> ByteString -> Maybe [Word32] -> Poker.Write
prepareWrite name sql parameterTypes =
  parseMessage name sql (fromMaybe [] parameterTypes) <> syncMessage

-- Sync-free variants used by the async send* functions.
-- In non-pipeline mode sendAsync appends syncMessage; in pipeline mode it does not.

asyncParamsWrite :: ByteString -> [Maybe (Word32, ByteString, Format)] -> Format -> Poker.Write
asyncParamsWrite sql params resultFormat =
  parseMessage "" sql (fmap paramOid params)
    <> bindMessage "" "" (fmap paramFormat params) (fmap paramValue params) [formatCodeOf resultFormat]
    <> describePortalMessage ""
    <> executeMessage "" 0

asyncPreparedWrite :: ByteString -> [Maybe (ByteString, Format)] -> Format -> Poker.Write
asyncPreparedWrite name params resultFormat =
  bindMessage "" name (fmap boundFormat params) (fmap boundValue params) [formatCodeOf resultFormat]
    <> describePortalMessage ""
    <> executeMessage "" 0

-- * Parameter-count limit

-- | The largest parameter count the wire protocol's 16-bit count fields can
-- carry, matching libpq's @PQ_QUERY_PARAM_MAX_LIMIT@. 'Parse' and 'Bind'
-- messages encode their parameter count as an 'Int16'; past this limit that
-- encoding wraps around instead of overflowing, so the count must be
-- rejected locally before it corrupts the message.
maxParamCount :: Int
maxParamCount = 65535

tooManyParams :: [a] -> Bool
tooManyParams = (> maxParamCount) . length

-- * Synchronous flows

-- | Simple query. Returns the last result, mirroring @PQexec@.
exec :: Connection -> ByteString -> IO (Maybe NativeResult)
exec connection sql = withReady connection $ guardLoss do
  sendMessage connection (queryMessage sql)
  lastMaybe <$> collectSimple connection sql

-- | Parameterized query via the extended protocol.
execParams :: Connection -> ByteString -> [Maybe (Word32, ByteString, Format)] -> Format -> IO (Maybe NativeResult)
execParams connection sql params resultFormat
  | tooManyParams params = pure Nothing
  | otherwise = withReady connection $ guardLoss do
      sendMessage connection (paramsWrite sql params resultFormat)
      Just <$> collectExtended connection sql

-- | Prepare a named statement.
prepare :: Connection -> ByteString -> ByteString -> Maybe [Word32] -> IO (Maybe NativeResult)
prepare connection name sql parameterTypes
  | maybe False tooManyParams parameterTypes = pure Nothing
  | otherwise = withReady connection $ guardLoss do
      sendMessage connection (prepareWrite name sql parameterTypes)
      Just <$> collectExtended connection sql

-- | Execute a previously prepared statement.
execPrepared :: Connection -> ByteString -> [Maybe (ByteString, Format)] -> Format -> IO (Maybe NativeResult)
execPrepared connection name params resultFormat
  | tooManyParams params = pure Nothing
  | otherwise = withReady connection $ guardLoss do
      sendMessage connection (preparedWrite name params resultFormat)
      Just <$> collectExtended connection ""

-- * Asynchronous flows

-- | Send a write in async mode, tracking pending commands for pipeline abort.
--
-- Sending never throws. A failed write is not reported by the send but by
-- the receive that follows, as in libpq (see 'sendMessage'), so a send on a
-- usable connection returns 'True'. A send on a connection that is not
-- usable returns 'False' and records why (see 'whenUsable').
sendAsync :: Connection -> ByteString -> Poker.Write -> IO Bool
sendAsync connection sql write = whenUsable connection False do
  sendMessage connection write
  writeIORef (currentQuery connection) sql
  writeIORef (asyncPending connection) True
  pipeStatus <- readIORef (pipelineStatus connection)
  when (pipeStatus /= PipelineOff) $ modifyIORef' (pendingCommands connection) (+ 1)
  pure True

-- | Whether the connection is in pipeline mode.
inPipeline :: Connection -> IO Bool
inPipeline connection = (/= PipelineOff) <$> readIORef (pipelineStatus connection)

-- Simple query protocol: no Sync needed (server sends ReadyForQuery on its own).
sendQuery :: Connection -> ByteString -> IO Bool
sendQuery connection sql = sendAsync connection sql (queryMessage sql)

-- Extended query: include Sync when not in pipeline mode; omit Sync in
-- pipeline mode (the caller drives sync boundaries via 'pipelineSync').
sendQueryParams :: Connection -> ByteString -> [Maybe (Word32, ByteString, Format)] -> Format -> IO Bool
sendQueryParams connection sql params resultFormat
  | tooManyParams params = pure False
  | otherwise = do
      pipeline <- inPipeline connection
      ok <-
        sendAsync connection sql
          $ if pipeline
            then asyncParamsWrite sql params resultFormat
            else paramsWrite sql params resultFormat
      -- 'sendQueryParams' drives the extended protocol and so always sends an
      -- unnamed @Parse@, whose @ParseComplete@ must fold into this command's own
      -- result (like 'sendQueryPrepared'). Record its origin so it is not mistaken
      -- for the terminal @ParseComplete@ of a pipelined 'sendPrepare'.
      when (ok && pipeline) $ modifyIORef' (pendingParseOrigins connection) (Seq.|> Just False)
      pure ok

sendPrepare :: Connection -> ByteString -> ByteString -> Maybe [Word32] -> IO Bool
sendPrepare connection name sql parameterTypes
  | maybe False tooManyParams parameterTypes = pure False
  | otherwise = do
      pipeline <- inPipeline connection
      ok <-
        sendAsync connection sql
          $ if pipeline
            then parseMessage name sql (fromMaybe [] parameterTypes)
            else prepareWrite name sql parameterTypes
      when (ok && pipeline) $ modifyIORef' (pendingParseOrigins connection) (Seq.|> Just True)
      pure ok

-- | Push a @Nothing@ origin for a pipelined command that sends no @Parse@
-- (so has no @ParseComplete@ to fold or terminate on): it still occupies one
-- slot in the FIFO, popped and discarded at its own terminal message.
sendAsyncNoOrigin :: Connection -> Bool -> ByteString -> Poker.Write -> IO Bool
sendAsyncNoOrigin connection pipeline sql write = do
  ok <- sendAsync connection sql write
  when (ok && pipeline) $ modifyIORef' (pendingParseOrigins connection) (Seq.|> Nothing)
  pure ok

sendQueryPrepared :: Connection -> ByteString -> [Maybe (ByteString, Format)] -> Format -> IO Bool
sendQueryPrepared connection name params resultFormat
  | tooManyParams params = pure False
  | otherwise = do
      pipeline <- inPipeline connection
      sendAsyncNoOrigin connection pipeline ""
        $ if pipeline
          then asyncPreparedWrite name params resultFormat
          else preparedWrite name params resultFormat

sendDescribePrepared :: Connection -> ByteString -> IO Bool
sendDescribePrepared connection name = do
  pipeline <- inPipeline connection
  sendAsyncNoOrigin connection pipeline ""
    $ if pipeline
      then describeStatementMessage name
      else describeStatementMessage name <> syncMessage

sendDescribePortal :: Connection -> ByteString -> IO Bool
sendDescribePortal connection name = do
  pipeline <- inPipeline connection
  sendAsyncNoOrigin connection pipeline ""
    $ if pipeline
      then describePortalMessage name
      else describePortalMessage name <> syncMessage

-- | Read the next result of an in-flight asynchronous command, or 'Nothing'
-- once @ReadyForQuery@ is reached (clearing the pending flag), mirroring
-- @PQgetResult@.
--
-- In pipeline mode a separator 'Nothing' is returned between each command's
-- result set, and a 'PipelineSync' result is returned for each @Sync@
-- boundary. In single-row mode each data row is delivered as a separate
-- 'SingleTuple' result followed by a final 'TuplesOk' with no rows.
--
-- Runs 'mask_'ed. The connection's result bookkeeping - the pending-command
-- counter, the separator flag, the @ParseComplete@ origin FIFO, the pipeline
-- status - lives in separate 'IORef's that a single logical transition
-- updates one after another. An async exception landing between two of those
-- updates leaves the pair inconsistent, and an inconsistent pair is not merely
-- wrong: it sends the next 'getNextResult' down the @readAndProcess@ path to
-- wait for a message the backend has already decided not to send, which is a
-- stall with no timer behind it. Masking keeps each transition atomic. The
-- blocking read inside stays interruptible (see 'Transport.receiveFrame'), so
-- this costs no abandonability.
--
-- A connection lost while waiting is reported as libpq reports it: this
-- returns the result libpq makes up for it (see 'guardLoss'), and the call
-- after that returns 'Nothing'.
getNextResult :: Connection -> IO (Maybe NativeResult)
getNextResult connection = mask_ $ guardLoss do
  pending <- readIORef (asyncPending connection)
  if not pending
    then pure Nothing
    else do
      sepPending <- readIORef (pipelineSeparatorPending connection)
      if sepPending
        then do
          writeIORef (pipelineSeparatorPending connection) False
          startNextCommand
          pure Nothing
        else do
          singleRow <- readIORef (singleRowMode connection)
          cachedFields <- readIORef (singleRowFields connection)
          builder <- newBuilder
          let initBuilder =
                if singleRow && not (null cachedFields)
                  then builder {accFields = cachedFields, accSawRowDescription = True}
                  else builder
          go singleRow initBuilder
  where
    -- Decrement the pending-command counter and set the separator flag when in
    -- pipeline mode.  Called when a "terminal" result is about to be returned.
    finishCommand pipeStatus = do
      when (pipeStatus /= PipelineOff) $ do
        modifyIORef' (pendingCommands connection) (subtract 1)
        writeIORef (pipelineSeparatorPending connection) True

    -- Where libpq moves on to the next queued command (@pqPipelineProcessQueue@)
    -- it clears the error message, which until then belongs to the command
    -- that has just been reported. Nothing is cleared when that was the last
    -- command.
    startNextCommand = do
      commands <- readIORef (pendingCommands connection)
      syncs <- readIORef (pendingSyncs connection)
      when (commands > 0 || syncs > 0) $ writeIORef (lastError connection) (Just "")

    -- Pop and discard this command's 'pendingParseOrigins' entry if it hasn't
    -- already been consumed by its own 'ParseComplete' (see the field's
    -- Haddock on 'Connection'). Called at every terminal message besides a
    -- 'ParseComplete' that itself terminates the command.
    popOriginIfPending pipeStatus builder =
      when (pipeStatus /= PipelineOff && not (accOriginPopped builder))
        $ void (popPendingParseOrigin connection)

    go singleRow builder = do
      pipeStatus <- readIORef (pipelineStatus connection)
      -- In aborted pipeline mode, if the server has already sent nothing for
      -- the remaining commands (it discards them after the first error), we
      -- generate synthetic PipelineAbort results for each outstanding command
      -- rather than blocking on a wire read that will never come.
      pending <- readIORef (pendingCommands connection)
      if pipeStatus == PipelineAborted && pending > 0
        then do
          -- This command is being synthesized without ever reading a
          -- message for it (the server sends nothing for a discarded
          -- command), so its origin - if it pushed one - is necessarily
          -- still unpopped.
          popOriginIfPending pipeStatus builder
          modifyIORef' (pendingCommands connection) (subtract 1)
          writeIORef (pipelineSeparatorPending connection) True
          pure (Just (NativeResult PipelineAbort [] [] Nothing Map.empty [] "" Nothing))
        else readAndProcess singleRow builder pipeStatus

    readAndProcess singleRow builder pipeStatus = do
      message <- receiveMessage connection
      case message of
        RowDescription fs ->
          go singleRow builder {accFields = fs, accSawRowDescription = True, accHadResponse = True}
        ParameterDescription oids ->
          go singleRow builder {accParamOids = oids, accHadResponse = True}
        NoData ->
          go singleRow builder {accHadResponse = True}
        DataRow values ->
          if singleRow
            then do
              writeIORef (singleRowFields connection) (accFields builder)
              pure (Just (NativeResult SingleTuple (accFields builder) [values] Nothing Map.empty [] "" Nothing))
            else do
              pushRow (accRows builder) values
              go singleRow builder
        ParseComplete -> do
          -- Charge the @ParseComplete@ to the command that produced it by
          -- popping the oldest recorded origin. Only a 'sendPrepare' origin
          -- (@True@) terminates the command as 'CommandOk'; a 'sendQueryParams'
          -- origin (@False@) folds into the accumulating result, and so does a
          -- @ParseComplete@ seen outside pipeline mode (e.g. a non-pipelined
          -- async 'sendQueryParams', whose result is collected by 'collectExtended').
          origin <-
            if pipeStatus /= PipelineOff
              then popPendingParseOrigin connection
              else pure Nothing
          case origin of
            Just (Just True) -> do
              finishCommand pipeStatus
              pure (Just (NativeResult CommandOk [] [] Nothing Map.empty [] "" Nothing))
            Just _ -> go singleRow builder {accHadResponse = True, accOriginPopped = True}
            Nothing -> go singleRow builder {accHadResponse = True}
        BindComplete ->
          go singleRow builder {accHadResponse = True}
        CloseComplete ->
          go singleRow builder {accHadResponse = True}
        CommandComplete tag -> do
          if singleRow
            then do
              writeIORef (singleRowMode connection) False
              writeIORef (singleRowFields connection) []
              writeIORef (lastError connection) (Just "")
              pure (Just (NativeResult TuplesOk (accFields builder) [] (Just tag) Map.empty [] "" Nothing))
            else do
              writeIORef (lastError connection) (Just "")
              popOriginIfPending pipeStatus builder
              finishCommand pipeStatus
              Just <$> commandResult builder (Just tag)
        EmptyQueryResponse -> do
          writeIORef (lastError connection) (Just "")
          popOriginIfPending pipeStatus builder
          finishCommand pipeStatus
          pure (Just (NativeResult EmptyQuery [] [] Nothing Map.empty [] "" Nothing))
        ErrorResponse fs -> do
          let errMap = Map.fromList fs
          case pipeStatus of
            PipelineAborted -> do
              -- Should not normally happen (server discards commands in abort
              -- mode) but handle defensively.
              popOriginIfPending pipeStatus builder
              finishCommand pipeStatus
              pure (Just (NativeResult PipelineAbort [] [] Nothing Map.empty [] "" Nothing))
            PipelineOn -> do
              writeIORef (pipelineStatus connection) PipelineAborted
              writeIORef (lastError connection) (Just (formatResultError "" errMap))
              popOriginIfPending pipeStatus builder
              finishCommand PipelineOn
              pure (Just (NativeResult FatalError [] [] Nothing errMap [] "" Nothing))
            PipelineOff -> do
              sql <- readIORef (currentQuery connection)
              writeIORef (lastError connection) (Just (formatResultError sql errMap))
              pure (Just (NativeResult FatalError [] [] Nothing errMap [] sql Nothing))
        PortalSuspended ->
          Just <$> commandResult builder Nothing
        ReadyForQuery txState -> do
          writeIORef (txStatus connection) txState
          case pipeStatus of
            PipelineOff -> do
              writeIORef (asyncPending connection) False
              if (accHadResponse builder)
                then Just <$> describeResult builder
                else pure Nothing
            _ -> do
              writeIORef (pipelineStatus connection) PipelineOn
              -- A PipelineSync result is its own command boundary: unlike a
              -- normal command result, libpq does not emit a separating NULL
              -- after it, so consecutive syncs are reported back-to-back. We
              -- therefore never set 'pipelineSeparatorPending' here. Only the
              -- final sync clears 'asyncPending'; an earlier one leaves it set
              -- so the next 'getNextResult' reads straight on to the next sync.
              remaining <- atomicModifyIORef' (pendingSyncs connection) (\n -> (n - 1, n - 1))
              when (remaining == 0) $ writeIORef (asyncPending connection) False
              startNextCommand
              pure (Just (NativeResult PipelineSync [] [] Nothing Map.empty [] "" Nothing))
        _ -> go singleRow builder

-- | Pop the next in-flight pipelined command's origin: @Just (Just True)@ if
-- it is the terminal @ParseComplete@ of a 'sendPrepare' (to be materialized
-- as 'CommandOk'), @Just (Just False)@ if it belongs to a 'sendQueryParams'
-- and must fold into that command's accumulating result, @Just Nothing@ for
-- a command with no @Parse@ step. The outer @Nothing@ means the FIFO is
-- empty (defensive: nothing is popped and the caller folds).
popPendingParseOrigin :: Connection -> IO (Maybe (Maybe Bool))
popPendingParseOrigin connection =
  atomicModifyIORef'
    (pendingParseOrigins connection)
    ( \queue -> case Seq.viewl queue of
        origin Seq.:< rest -> (rest, Just origin)
        Seq.EmptyL -> (queue, Nothing)
    )

-- | Describe a prepared statement.
describePrepared :: Connection -> ByteString -> IO (Maybe NativeResult)
describePrepared connection name = withReady connection $ guardLoss do
  sendMessage connection (describeStatementMessage name <> syncMessage)
  Just <$> collectExtended connection ""

-- | Describe a portal.
describePortal :: Connection -> ByteString -> IO (Maybe NativeResult)
describePortal connection name = withReady connection $ guardLoss do
  sendMessage connection (describePortalMessage name <> syncMessage)
  Just <$> collectExtended connection ""

-- * Parameter projections

paramOid :: Maybe (Word32, ByteString, Format) -> Word32
paramOid = maybe 0 (\(oid, _, _) -> oid)

paramFormat :: Maybe (Word32, ByteString, Format) -> Int16
paramFormat = maybe 0 (\(_, _, format) -> formatCodeOf format)

paramValue :: Maybe (Word32, ByteString, Format) -> Maybe ByteString
paramValue = fmap (\(_, value, _) -> value)

boundFormat :: Maybe (ByteString, Format) -> Int16
boundFormat = maybe 0 (formatCodeOf . snd)

boundValue :: Maybe (ByteString, Format) -> Maybe ByteString
boundValue = fmap fst

-- | Renamed from @formatCode@ to avoid clashing with 'FieldDescription's
-- @formatCode@ field now that 'DuplicateRecordFields' is no longer enabled.
formatCodeOf :: Format -> Int16
formatCodeOf = \case
  Text -> 0
  Binary -> 1

-- * Result collection

-- | Only run a flow on a ready connection; mirror libpq returning no result
-- when the connection is not usable, with \"no connection to the server\" as
-- the error message (see 'whenUsable').
withReady :: Connection -> IO (Maybe a) -> IO (Maybe a)
withReady connection = whenUsable connection Nothing

-- | Run a flow that reads results off the connection, and if the connection
-- is lost while it does ('receiveMessage' has recorded the loss by then),
-- return the result libpq makes up for it. This is also what @PQexec@ returns
-- when the loss comes after results that were already received, as it is the
-- last result that counts. Everything else, an asynchronous exception
-- included, propagates.
guardLoss :: IO (Maybe NativeResult) -> IO (Maybe NativeResult)
guardLoss flow =
  flow `catch` \(ConnectionLost message) -> pure (Just (lostConnectionResult message))

-- | A growable row buffer, replacing a cons-list-plus-reverse accumulation
-- with amortized-O(1) appends into a doubling mutable vector. A statement's
-- rows are pushed one 'DataRow' at a time and can number in the hundreds of
-- thousands; accumulating them as @row : rows@ and reversing at the end (the
-- prior representation) keeps two full-length cons-cell spines alive across
-- the whole statement, which dominates GC copy volume at scale (see
-- 'hasql-pqi-native-decode-gc-bound' memory). This buffer holds only the
-- array (which the RTS moves as one contiguous block, unlike a linked list)
-- until 'freezeRowBuffer' walks it once into the final list.
data RowBuffer
  = RowBuffer
      -- | The mutable vector of rows.
      !(IORef (MVector.IOVector [Maybe ByteString]))
      -- | Length.
      !(IORef Int)

newRowBuffer :: IO RowBuffer
newRowBuffer = do
  v <- MVector.new 64
  RowBuffer <$> newIORef v <*> newIORef 0

pushRow :: RowBuffer -> [Maybe ByteString] -> IO ()
pushRow (RowBuffer vRef lenRef) row = do
  v <- readIORef vRef
  len <- readIORef lenRef
  v' <-
    if len >= MVector.length v
      then do
        grown <- MVector.grow v (MVector.length v)
        writeIORef vRef grown
        pure grown
      else pure v
  MVector.write v' len row
  writeIORef lenRef (len + 1)

-- | Walk the buffer back-to-front once, consing onto an accumulator, which
-- yields the rows in their original order without a separate reverse pass.
freezeRowBuffer :: RowBuffer -> IO [[Maybe ByteString]]
freezeRowBuffer (RowBuffer vRef lenRef) = do
  v <- readIORef vRef
  len <- readIORef lenRef
  let go !i acc
        | i < 0 = pure acc
        | otherwise = do
            x <- MVector.read v i
            go (i - 1) (x : acc)
  go (len - 1) []

-- accumulator for a result under construction
data Builder = Builder
  { accFields :: [FieldDescription],
    accRows :: RowBuffer,
    accParamOids :: [Word32],
    accSawRowDescription :: Bool,
    accHadResponse :: Bool,
    -- | Whether this command's 'pendingParseOrigins' entry has already been
    -- popped (via its own 'ParseComplete'). See 'popOriginIfPending'.
    accOriginPopped :: Bool
  }

newBuilder :: IO Builder
newBuilder = do
  rows <- newRowBuffer
  pure (Builder [] rows [] False False False)

-- | Collect the (possibly several) results of a simple query, up to
-- @ReadyForQuery@. The last is what @PQexec@ returns.
-- @CopyInResponse@ and @CopyOutResponse@ terminate the loop immediately,
-- returning a synthetic result so the caller can enter the copy sub-protocol.
collectSimple :: Connection -> ByteString -> IO [NativeResult]
collectSimple connection sql = do
  builder0 <- newBuilder
  go builder0 []
  where
    go builder acc = do
      message <- receiveMessage connection
      case message of
        RowDescription fs -> go builder {accFields = fs, accSawRowDescription = True} acc
        DataRow values -> do
          pushRow (accRows builder) values
          go builder acc
        CommandComplete tag -> do
          writeIORef (lastError connection) (Just "")
          result <- commandResult builder (Just tag)
          builder' <- newBuilder
          go builder' (result : acc)
        EmptyQueryResponse -> do
          writeIORef (lastError connection) (Just "")
          builder' <- newBuilder
          go builder' (NativeResult EmptyQuery [] [] Nothing Map.empty [] "" Nothing : acc)
        ErrorResponse fs -> do
          let errMap = Map.fromList fs
          writeIORef (lastError connection) (Just (formatResultError sql errMap))
          builder' <- newBuilder
          go builder' (NativeResult FatalError [] [] Nothing errMap [] sql Nothing : acc)
        CopyInResponse _ formats ->
          let fields = map copyField formats
           in pure (reverse (NativeResult CopyIn fields [] Nothing Map.empty [] "" Nothing : acc))
        CopyOutResponse _ formats ->
          let fields = map copyField formats
           in pure (reverse (NativeResult CopyOut fields [] Nothing Map.empty [] "" Nothing : acc))
        ReadyForQuery txState -> do
          writeIORef (txStatus connection) txState
          pure (reverse acc)
        _ -> go builder acc

-- | Collect the single result of an extended-protocol command.
collectExtended :: Connection -> ByteString -> IO NativeResult
collectExtended connection sql = do
  builder0 <- newBuilder
  go builder0 Nothing
  where
    go builder finished = do
      message <- receiveMessage connection
      case message of
        RowDescription fs -> go builder {accFields = fs, accSawRowDescription = True} finished
        ParameterDescription oids -> go builder {accParamOids = oids} finished
        NoData -> go builder finished
        DataRow values -> do
          pushRow (accRows builder) values
          go builder finished
        ParseComplete -> go builder finished
        BindComplete -> go builder finished
        CloseComplete -> go builder finished
        PortalSuspended -> do
          finished' <- case finished of
            Just _ -> pure finished
            Nothing -> Just <$> commandResult builder Nothing
          builder' <- newBuilder
          go builder' finished'
        CommandComplete tag -> do
          writeIORef (lastError connection) (Just "")
          result <- commandResult builder (Just tag)
          builder' <- newBuilder
          go builder' (Just result)
        EmptyQueryResponse -> do
          writeIORef (lastError connection) (Just "")
          builder' <- newBuilder
          go builder' (Just (NativeResult EmptyQuery [] [] Nothing Map.empty [] "" Nothing))
        ErrorResponse fs -> do
          let errMap = Map.fromList fs
          writeIORef (lastError connection) (Just (formatResultError sql errMap))
          builder' <- newBuilder
          go builder' (Just (NativeResult FatalError [] [] Nothing errMap [] sql Nothing))
        ReadyForQuery txState -> do
          writeIORef (txStatus connection) txState
          case finished of
            Just result -> pure result
            Nothing -> describeResult builder
        _ -> go builder finished

-- | A result terminated by @CommandComplete@\/@PortalSuspended@: 'TuplesOk' if a
-- row description was seen, else 'CommandOk'.
commandResult :: Builder -> Maybe ByteString -> IO NativeResult
commandResult builder tag = do
  rows <- freezeRowBuffer (accRows builder)
  pure
    $ NativeResult
      (if (accSawRowDescription builder) then TuplesOk else CommandOk)
      (accFields builder)
      rows
      tag
      Map.empty
      (accParamOids builder)
      ""
      Nothing

-- | A result with no command completion (a @Describe@\/@Parse@-only flow):
-- 'CommandOk', carrying any column descriptions and parameter OIDs.
describeResult :: Builder -> IO NativeResult
describeResult builder = do
  rows <- freezeRowBuffer (accRows builder)
  pure
    $ NativeResult
      CommandOk
      (accFields builder)
      rows
      Nothing
      Map.empty
      (accParamOids builder)
      ""
      Nothing

lastMaybe :: [a] -> Maybe a
lastMaybe = foldl (\_ x -> Just x) Nothing

-- | Build a synthetic 'FieldDescription' from a COPY format code (0=text,
-- 1=binary).  COPY results have no column names, table OID, type OID, etc.
copyField :: Int16 -> FieldDescription
copyField fmt =
  FieldDescription
    { name = "",
      tableOid = 0,
      columnAttributeNumber = 0,
      typeOid = 0,
      typeSize = 0,
      typeModifier = 0,
      formatCode = fmt
    }