packages feed

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

-- | Internal types for the native adapter, separated from 'Connection.hs' to
-- avoid orphan-instance warnings for 'IsResult' and 'IsCancel'.
module Pqi.Native.Types
  ( NativeResult (..),
    NativeCancel (..),
    formatErrorFields,
    formatResultError,
  )
where

import Control.Exception (IOException, try)
import qualified Data.ByteString as ByteString
import qualified Data.ByteString.Char8 as ByteString.Char8
import Data.Char (isDigit)
import Data.List (findIndex)
import qualified Data.Map.Strict as Map
import Pqi
  ( ExecStatus (..),
    FieldCode (..),
    Format (..),
    IsCancel (..),
    IsResult (..),
    PipelineStatus (..),
  )
import Pqi.Native.Prelude
import qualified Pqi.Native.Transport as Transport
import Pqi.Native.Transport.Message (FieldDescription (..), cancelRequest)

-- | A fully materialized result. The native adapter buffers the entire result
-- in memory, so the accessors are pure lookups and 'unsafeFreeResult' is a
-- no-op.
data NativeResult = NativeResult
  { status :: ExecStatus,
    fields :: [FieldDescription],
    rows :: [[Maybe ByteString]],
    commandTag :: Maybe ByteString,
    errorFields :: Map.Map Word8 ByteString,
    paramOids :: [Word32],
    -- | The query text that produced this result (used to format @LINE N:@
    -- position context in 'resultErrorMessage', matching libpq's behaviour).
    -- Empty for non-error results and for async results where the query is
    -- unavailable.
    queryText :: ByteString
  }
  deriving stock (Eq, Show)

-- | A standalone cancellation handle for the native adapter.
--
-- Carries references to connection state so the cancel implementation can
-- decide whether a network round-trip is necessary:
--
-- * @asyncPendingRef@: False when nothing is in flight at all — skip the
--   round-trip entirely.
-- * @pipelineStatusRef@ + @pendingCommandsRef@: in pipeline mode,
--   @asyncPending@ stays True even after all @CommandComplete@ messages
--   have arrived (while @ReadyForQuery@ is still unread).  Sending a cancel
--   in that window produces a stale signal that can interrupt the very next
--   command (e.g. the @ABORT@ issued during clean-up).  Checking
--   @pendingCommands > 0@ instead avoids the stale cancel: once all command
--   completions are in, @pendingCommands@ is 0 and the server is idle.
data NativeCancel = NativeCancel
  { host :: ByteString,
    port :: Int,
    pid :: Int32,
    secret :: Int32,
    asyncPendingRef :: IORef Bool,
    pipelineStatusRef :: IORef PipelineStatus,
    pendingCommandsRef :: IORef Int
  }

-- | Like 'formatResultError' but without a client query text (for
-- connection-level errors, which never carry a statement-position field).
formatErrorFields :: Map.Map Word8 ByteString -> ByteString
formatErrorFields = formatResultError ""

-- | Format error\/notice fields into a message string matching libpq's
-- @PQresultErrorMessage@ at DEFAULT verbosity with @SHOW_CONTEXT_ERRORS@
-- visibility.
--
-- Field order (matching libpq):
-- 1. @\<Severity\>:  \<Message\>\\n@
-- 2. @LINE N: \<query line\>\\n        \^\\n@ (statement position via @queryText@ + @\'P\'@ field)
-- 3. @LINE N: \<internal query\>\\n        \^\\nQUERY: \<internal query\>\\n@ (@\'q\'@+@\'p\'@ fields)
-- 4. @DETAIL:  \<D\>\\n@
-- 5. @HINT:  \<H\>\\n@
-- 6. @CONTEXT:  \<W\>\\n@ (ERROR\/FATAL\/PANIC only)
formatResultError :: ByteString -> Map.Map Word8 ByteString -> ByteString
formatResultError queryText fields =
  case Map.lookup 0x4d fields of
    Nothing -> ""
    Just msg ->
      let sev = Map.findWithDefault "" 0x53 fields
          isError = sev `elem` ["ERROR", "FATAL", "PANIC"]
          line1 = (if ByteString.null sev then msg else sev <> ":  " <> msg) <> "\n"
          -- Statement position ('P', 0x50): needs client query text
          stmtCtx = case Map.lookup 0x50 fields of
            Just posStr
              | not (ByteString.null queryText) ->
                  maybe "" (positionContext queryText) (readPositiveInt posStr)
            _ -> ""
          -- Internal query+position ('q'=0x71, 'p'=0x70): both in wire fields
          intCtx = case (Map.lookup 0x71 fields, Map.lookup 0x70 fields) of
            (Just intQuery, Just posStr) ->
              maybe
                ""
                (\p -> positionContext intQuery p <> "QUERY:  " <> intQuery <> "\n")
                (readPositiveInt posStr)
            _ -> ""
          detLine = case Map.lookup 0x44 fields of
            Just det -> "DETAIL:  " <> det <> "\n"
            _ -> ""
          hntLine = case Map.lookup 0x48 fields of
            Just hnt -> "HINT:  " <> hnt <> "\n"
            _ -> ""
          ctxLine = case Map.lookup 0x57 fields of
            Just ctx | isError -> "CONTEXT:  " <> ctx <> "\n"
            _ -> ""
       in line1 <> stmtCtx <> intCtx <> detLine <> hntLine <> ctxLine

-- | Build the @LINE N: \<text\>\\n        \^\\n@ block for a 1-indexed position
-- within a query string, matching libpq's formatting exactly.
positionContext :: ByteString -> Int -> ByteString
positionContext query pos =
  let pos0 = max 0 (pos - 1)
      before = ByteString.take pos0 query
      lineNum = 1 + ByteString.length (ByteString.filter (== 0x0a) before)
      lineStart = maybe 0 (+ 1) (ByteString.elemIndexEnd 0x0a before)
      col = pos0 - lineStart
      rest = ByteString.drop lineStart query
      lineText = ByteString.takeWhile (/= 0x0a) rest
      prefix = ByteString.Char8.pack ("LINE " <> show lineNum <> ": ")
      caret = ByteString.replicate (ByteString.length prefix + col) 0x20 <> "^\n"
   in prefix <> lineText <> "\n" <> caret

readPositiveInt :: ByteString -> Maybe Int
readPositiveInt bs = case ByteString.Char8.readInt bs of
  Just (n, _) | n > 0 -> Just n
  _ -> Nothing

instance IsResult NativeResult where
  resultStatus result = pure result.status
  resultErrorMessage result = pure (Just (formatResultError result.queryText result.errorFields))
  resultErrorField result field = pure (Map.lookup (fieldCodeByte field) result.errorFields)
  unsafeFreeResult _ = pure ()
  ntuples result = pure (fromIntegral (length result.rows))
  nfields result = pure (fromIntegral (length result.fields))
  fname result column = pure $ do
    fd <- atMay result.fields column
    if ByteString.null fd.name then Nothing else Just fd.name
  fnumber result name =
    pure (fromIntegral <$> findIndex (\field -> field.name == folded) result.fields)
    where
      folded = foldIdentifier name
  ftable result column = pure (maybe 0 (.tableOid) (atMay result.fields column))
  ftablecol result column =
    pure (maybe 0 (\field -> fromIntegral (field.columnAttributeNumber :: Int16)) (atMay result.fields column))
  fformat result column =
    pure (maybe Text (\field -> formatOf field.formatCode) (atMay result.fields column))
  ftype result column = pure (maybe 0 (.typeOid) (atMay result.fields column))
  fmod result column = pure (maybe 0 (\field -> fromIntegral (field.typeModifier :: Int32)) (atMay result.fields column))
  fsize result column = pure (maybe 0 (\field -> fromIntegral (field.typeSize :: Int16)) (atMay result.fields column))
  getvalue result row column = pure (join (cellAt result row column))
  getvalue' result row column = pure (join (cellAt result row column))
  getisnull result row column = pure (maybe True isNothing (cellAt result row column))
  getlength result row column =
    pure (maybe 0 (maybe 0 ByteString.length) (cellAt result row column))
  nparams result = pure (fromIntegral (length result.paramOids))
  paramtype result index = pure (fromMaybe 0 (atMay result.paramOids index))
  cmdStatus result = pure (Just (fromMaybe "" result.commandTag))
  cmdTuples result = pure (Just (maybe "" affectedRows result.commandTag))

instance IsCancel NativeCancel where
  cancel nc = do
    pending <- readIORef nc.asyncPendingRef
    if not pending
      then pure (Right ())
      else do
        transport <- Transport.connect nc.host nc.port
        Transport.send transport (cancelRequest nc.pid nc.secret)
        -- Read until EOF to ensure the server has processed the cancel request
        -- before we close the connection. This matches libpq's PQcancel behavior
        -- and prevents the cancel signal from racing with the next query.
        _ <- try @IOException (Transport.readUntilClosed transport)
        Transport.close transport
        pure (Right ())

-- * Helpers for the 'IsResult' instance

atMay :: [a] -> Int32 -> Maybe a
atMay xs i
  | i < 0 = Nothing
  | otherwise = case drop (fromIntegral i) xs of
      x : _ -> Just x
      [] -> Nothing

cellAt :: NativeResult -> Int32 -> Int32 -> Maybe (Maybe ByteString)
cellAt result row column = do
  rowValues <- atMay result.rows row
  atMay rowValues column

formatOf :: Int16 -> Format
formatOf = \case
  1 -> Binary
  _ -> Text

foldIdentifier :: ByteString -> ByteString
foldIdentifier = ByteString.pack . outside . ByteString.unpack
  where
    quote = 0x22
    outside = \case
      [] -> []
      c : rest
        | c == quote -> inside rest
        | otherwise -> asciiToLower c : outside rest
    inside = \case
      [] -> []
      c : rest
        | c == quote -> case rest of
            c' : rest' | c' == quote -> quote : inside rest'
            _ -> outside rest
        | otherwise -> c : inside rest
    asciiToLower c
      | c >= 0x41 && c <= 0x5a = c + 0x20
      | otherwise = c

fieldCodeByte :: FieldCode -> Word8
fieldCodeByte = \case
  DiagSeverity -> 0x53 -- 'S'
  DiagSqlstate -> 0x43 -- 'C'
  DiagMessagePrimary -> 0x4d -- 'M'
  DiagMessageDetail -> 0x44 -- 'D'
  DiagMessageHint -> 0x48 -- 'H'
  DiagStatementPosition -> 0x50 -- 'P'
  DiagInternalPosition -> 0x70 -- 'p'
  DiagInternalQuery -> 0x71 -- 'q'
  DiagContext -> 0x57 -- 'W'
  DiagSourceFile -> 0x46 -- 'F'
  DiagSourceLine -> 0x4c -- 'L'
  DiagSourceFunction -> 0x52 -- 'R'

-- | The affected-row count from a @CommandComplete@ tag, matching
-- @PQcmdTuples@: the last whitespace-delimited token if it is all digits (which
-- covers @INSERT oid rows@, @UPDATE n@, @SELECT n@, …), else empty.
affectedRows :: ByteString -> ByteString
affectedRows tag =
  case ByteString.Char8.words tag of
    [] -> ""
    tokens ->
      let final = last tokens
       in if not (ByteString.null final) && ByteString.Char8.all isDigit final
            then final
            else ""