packages feed

valiant-0.1.0.0: src/Valiant/Copy.hs

{-# OPTIONS_GHC -fno-full-laziness #-}

-- | COPY protocol support for bulk data import/export.
--
-- COPY operations require exclusive access to the connection's wire,
-- so they use 'submitExclusive' to pause the async pipeline.
module Valiant.Copy
  ( copyIn
  , copyOut
  , copyInBinary
  , CopyResult (..)
  ) where

import Control.Exception (SomeException, catch, throwIO)
import Data.ByteString (ByteString)
import Data.ByteString qualified as BS
import Data.ByteString.Builder qualified as B
import Data.ByteString.Char8 qualified as BS8
import Data.ByteString.Lazy qualified as LBS
import Data.IORef
import Data.Int (Int16, Int64)
import Data.Vector (Vector)
import Data.Vector qualified as V
import PgWire.Async (submitExclusive)
import PgWire.Connection (Connection (..))
import PgWire.Error (PgWireError (..), throwPgWire)
import PgWire.Protocol.Backend
import PgWire.Protocol.Frontend
import PgWire.Wire (WireConn, recvBackendMsg, sendFrontendMsg)

-- | Result of a COPY operation, containing the number of rows transferred.
data CopyResult = CopyResult
  { copyRows :: Int64
  -- ^ Number of rows copied, as reported by the server's @CommandComplete@ tag.
  }
  deriving stock (Show, Eq)

-- | Execute a @COPY ... FROM STDIN@ command, sending data in chunks.
--
-- Usage:
--
-- > copyIn conn "COPY users (name, email) FROM STDIN WITH (FORMAT csv)" $ \sendChunk -> do
-- >   sendChunk "Alice,alice@example.com\n"
-- >   sendChunk "Bob,bob@example.com\n"
copyIn :: Connection -> ByteString -> ((ByteString -> IO ()) -> IO ()) -> IO CopyResult
copyIn conn sql producer =
  submitExclusive (connAsync conn) $ \wc txRef -> do
    sendFrontendMsg wc (Query sql)
    waitCopyIn wc
    producer (sendFrontendMsg wc . CopyData)
      `catch` \(e :: SomeException) -> do
        -- Best-effort: send CopyFail and drain to ReadyForQuery so the
        -- connection is usable. If the wire is dead, ignore the cleanup
        -- failure and re-throw the original exception.
        (do sendFrontendMsg wc (CopyFail (BS8.pack (show e)))
            drainToReady wc txRef
          ) `catch` \(_ :: SomeException) -> pure ()
        throwIO e
    sendFrontendMsg wc CopyDone
    collectCopyResult wc txRef

-- | Execute a @COPY ... TO STDOUT@ command, receiving data in chunks
-- via a callback.
--
-- Usage:
--
-- > copyOut conn "COPY users TO STDOUT WITH (FORMAT csv)" $ \chunk -> do
-- >   BS.putStr chunk
copyOut :: Connection -> ByteString -> (ByteString -> IO ()) -> IO CopyResult
copyOut conn sql consumer =
  submitExclusive (connAsync conn) $ \wc txRef -> do
    sendFrontendMsg wc (Query sql)
    waitCopyOut wc
    let loop = do
          msg <- recvBackendMsg wc
          case msg of
            CopyDataMsg chunk -> do
              consumer chunk
              loop
            CopyDoneMsg -> pure ()
            ErrorResponse err -> throwPgWire (QueryError err)
            other -> throwPgWire (ProtocolError ("Unexpected in COPY OUT: " <> BS8.pack (show other)))
    loop
    collectCopyResult wc txRef

-- | Execute a @COPY ... FROM STDIN WITH (FORMAT binary)@ command,
-- sending rows in PostgreSQL's binary COPY format.
--
-- Each row is provided as a @Vector (Maybe ByteString)@ where each
-- element is an already-encoded binary value (using 'pgEncode'), or
-- 'Nothing' for NULL.
--
-- @
-- copyInBinary conn
--   \"COPY users (id, name) FROM STDIN WITH (FORMAT binary)\"
--   2  -- number of columns
--   $ \\sendRow -> do
--     sendRow (V.fromList [Just (pgEncode (1 :: Int32)), Just (pgEncode (\"Alice\" :: Text))])
--     sendRow (V.fromList [Just (pgEncode (2 :: Int32)), Just (pgEncode (\"Bob\" :: Text))])
-- @
copyInBinary
  :: Connection
  -> ByteString
  -- ^ COPY ... FROM STDIN WITH (FORMAT binary) statement
  -> Int16
  -- ^ Number of columns
  -> ((Vector (Maybe ByteString) -> IO ()) -> IO ())
  -- ^ Producer: call @sendRow@ for each row
  -> IO CopyResult
copyInBinary conn sql numCols producer =
  submitExclusive (connAsync conn) $ \wc txRef -> do
    sendFrontendMsg wc (Query sql)
    waitCopyIn wc
    sendFrontendMsg wc (CopyData binaryCopyHeader)
    let sendRow row = do
          let rowBytes = encodeBinaryRow numCols row
          sendFrontendMsg wc (CopyData rowBytes)
    producer sendRow
      `catch` \(e :: SomeException) -> do
        (do sendFrontendMsg wc (CopyFail (BS8.pack (show e)))
            drainToReady wc txRef
          ) `catch` \(_ :: SomeException) -> pure ()
        throwIO e
    sendFrontendMsg wc (CopyData binaryCopyTrailer)
    sendFrontendMsg wc CopyDone
    collectCopyResult wc txRef

-- | Binary COPY header:
-- 11-byte signature: "PGCOPY\n\377\r\n\0"
-- 4-byte flags: 0 (no OID inclusion)
-- 4-byte header extension area length: 0
binaryCopyHeader :: ByteString
binaryCopyHeader = LBS.toStrict . B.toLazyByteString $
  B.byteString "PGCOPY\n\xff\r\n\0"
    <> B.int32BE 0  -- flags
    <> B.int32BE 0  -- header extension length

-- | Binary COPY trailer: -1 as Int16
binaryCopyTrailer :: ByteString
binaryCopyTrailer = LBS.toStrict . B.toLazyByteString $
  B.int16BE (-1)

-- | Encode a single row in binary COPY format:
-- Int16 field count, then per field: Int32 length (-1 for NULL) + data
encodeBinaryRow :: Int16 -> Vector (Maybe ByteString) -> ByteString
encodeBinaryRow numCols row = LBS.toStrict . B.toLazyByteString $
  B.int16BE numCols
    <> V.foldl' (\acc mv -> acc <> encodeField mv) mempty row
  where
    encodeField Nothing = B.int32BE (-1)
    encodeField (Just bs) = B.int32BE (fromIntegral (BS.length bs)) <> B.byteString bs

-- Internal ------------------------------------------------------------------

waitCopyIn :: WireConn -> IO ()
waitCopyIn wc = do
  msg <- recvBackendMsg wc
  case msg of
    CopyInResponse _ _ -> pure ()
    ErrorResponse err -> throwPgWire (QueryError err)
    other -> throwPgWire (ProtocolError ("Expected CopyInResponse, got: " <> BS8.pack (show other)))

waitCopyOut :: WireConn -> IO ()
waitCopyOut wc = do
  msg <- recvBackendMsg wc
  case msg of
    CopyOutResponse _ _ -> pure ()
    ErrorResponse err -> throwPgWire (QueryError err)
    other -> throwPgWire (ProtocolError ("Expected CopyOutResponse, got: " <> BS8.pack (show other)))

collectCopyResult :: WireConn -> IORef TxStatus -> IO CopyResult
collectCopyResult wc txRef = go 0
  where
    go !n = do
      msg <- recvBackendMsg wc
      case msg of
        CommandComplete tag -> go (tagRows tag)
        ReadyForQuery status -> do
          writeIORef txRef status
          pure (CopyResult n)
        ErrorResponse err -> throwPgWire (QueryError err)
        NoticeResponse _ -> go n
        other -> throwPgWire (ProtocolError ("Unexpected in COPY result: " <> BS8.pack (show other)))

    tagRows (InsertTag r) = r
    tagRows (UpdateTag r) = r
    tagRows (DeleteTag r) = r
    tagRows (SelectTag r) = r
    tagRows (OtherTag t) =
      let parts = BS8.words t
       in case parts of
            [_, numStr] -> maybe 0 (fromIntegral . fst) (BS8.readInt numStr)
            _ -> 0

-- | Drain messages until ReadyForQuery, ignoring errors.
-- Used after CopyFail to ensure the connection is in a clean state.
drainToReady :: WireConn -> IORef TxStatus -> IO ()
drainToReady wc txRef = go
  where
    go = do
      msg <- recvBackendMsg wc
      case msg of
        ReadyForQuery status -> writeIORef txRef status
        _ -> go