packages feed

valiant-cli-0.1.0.0: src/Valiant/CLI/Describe.hs

module Valiant.CLI.Describe
  ( QueryMeta (..)
  , ParamMeta (..)
  , ColumnMeta (..)
  , DescribeError (..)
  , PgTypeInfo (..)
  , PgTypeCategory (..)
  , describeQuery
  , queryTypeInfo
  , queryEnumLabels
  , queryRangeSubtype
  , withPgConnection
  ) where

import Control.Exception (bracket, try)
import Data.ByteString (ByteString)
import Data.ByteString.Char8 qualified as BS8
import Data.IORef (IORef, writeIORef)
import Data.Text (Text)
import Data.Text.Encoding qualified as TE
import Data.Vector qualified as V
import Data.Vector (Vector)
import Data.Word (Word32)
import PgWire.Async (submitExclusive)
import PgWire.Connection (Connection, close, connAsync, connectString, simpleQuery)
import PgWire.Error (PgWireError)
import PgWire.Protocol.Backend (BackendMsg (..), FieldInfo (..), PgError (..), TxStatus)
import PgWire.Protocol.Frontend (DescribeTarget (..), FrontendMsg (..))
import PgWire.Protocol.Oid (Oid (..))
import PgWire.Wire (WireConn, recvBackendMsg, sendFrontendMsg)
import Valiant.CLI.Discover (SqlFile (..))

-- | Raw metadata returned by Postgres for a described query.
data QueryMeta = QueryMeta
  { qmParams :: [ParamMeta]
  , qmColumns :: [ColumnMeta]
  }
  deriving stock (Show)

-- | Metadata for a single query parameter.
data ParamMeta = ParamMeta
  { pmIndex :: Int
  , pmOid :: Oid
  }
  deriving stock (Show)

-- | Metadata for a single result column.
data ColumnMeta = ColumnMeta
  { cmName :: Text
  , cmOid :: Oid
  , cmTableOid :: Oid
  , cmColumnNumber :: Int
  }
  deriving stock (Show)

-- | An error that occurred while describing a query.
data DescribeError = DescribeError
  { deMessage :: Text
  , deDetail :: Maybe Text
  , deHint :: Maybe Text
  }
  deriving stock (Show)

-- | Connect to Postgres, run an action, and close the connection.
withPgConnection :: ByteString -> (Connection -> IO a) -> IO a
withPgConnection connStr action = do
  result <- try @PgWireError (bracket (connectString connStr) close action)
  case result of
    Right a -> pure a
    Left err -> error $ "valiant: connection failed: " <> show err

-- | Prepare and describe a SQL query, returning structured metadata.
describeQuery :: Connection -> SqlFile -> IO (Either DescribeError QueryMeta)
describeQuery conn sqlFile = do
  let stmtName = BS8.pack ("valiant_" <> sqlRelPath sqlFile)
      sql = sqlContent sqlFile
  result <- parseAndDescribe conn stmtName sql
  pure $ case result of
    Left err -> Left (pgErrorToDescribeError err)
    Right (paramOids, fields) -> Right (buildMeta paramOids fields)

-- | Send Parse + Describe + Sync in one round trip and collect the response.
-- Returns the parameter OIDs and column field info, or the server's error.
parseAndDescribe
  :: Connection
  -> ByteString
  -- ^ statement name
  -> ByteString
  -- ^ SQL text
  -> IO (Either PgError (Vector Word32, Vector FieldInfo))
parseAndDescribe conn stmtName sql =
  submitExclusive (connAsync conn) $ \wc txRef -> do
    sendFrontendMsg wc (Parse stmtName sql V.empty)
    sendFrontendMsg wc (Describe DescribeStatement stmtName)
    sendFrontendMsg wc Sync
    collectDescribeResponse wc txRef

-- | Collect the response to a Parse + Describe + Sync sequence.
-- Expected message order on success:
-- ParseComplete, ParameterDescription, (RowDescription | NoData), ReadyForQuery.
-- On error: ErrorResponse, ReadyForQuery.
collectDescribeResponse
  :: WireConn
  -> IORef TxStatus
  -> IO (Either PgError (Vector Word32, Vector FieldInfo))
collectDescribeResponse wc txRef = loop Nothing V.empty V.empty
  where
    loop !mErr !paramOids !fields = do
      msg <- recvBackendMsg wc
      case msg of
        ParseComplete -> loop mErr paramOids fields
        ParameterDescription oids -> loop mErr oids fields
        RowDescription fs -> loop mErr paramOids fs
        NoData -> loop mErr paramOids V.empty
        ErrorResponse err -> loop (Just err) paramOids fields
        ReadyForQuery status -> do
          writeIORef txRef status
          pure $ case mErr of
            Just err -> Left err
            Nothing -> Right (paramOids, fields)
        _ -> loop mErr paramOids fields

buildMeta :: Vector Word32 -> Vector FieldInfo -> QueryMeta
buildMeta paramOids fields =
  QueryMeta
    { qmParams =
        [ ParamMeta {pmIndex = i + 1, pmOid = Oid oid}
        | (i, oid) <- zip [0 ..] (V.toList paramOids)
        ]
    , qmColumns = map fieldToColumnMeta (V.toList fields)
    }

fieldToColumnMeta :: FieldInfo -> ColumnMeta
fieldToColumnMeta fi =
  ColumnMeta
    { cmName = TE.decodeUtf8 (fiName fi)
    , cmOid = Oid (fiTypeOid fi)
    , cmTableOid = Oid (fiTableOid fi)
    , cmColumnNumber = fromIntegral (fiColumnNum fi)
    }

pgErrorToDescribeError :: PgError -> DescribeError
pgErrorToDescribeError err =
  DescribeError
    { deMessage = TE.decodeUtf8 (pgMessage err)
    , deDetail = TE.decodeUtf8 <$> pgDetail err
    , deHint = TE.decodeUtf8 <$> pgHint err
    }

-- Type discovery -------------------------------------------------------------

-- | Category of a PG type discovered from @pg_type@.
data PgTypeCategory
  = PgEnum
  | PgComposite
  | PgDomain
  | PgRange
  | PgBase
  | PgPseudo
  deriving stock (Show, Eq)

-- | Metadata about a PG type, queried from @pg_type@.
data PgTypeInfo = PgTypeInfo
  { ptiName :: Text
  , ptiCategory :: PgTypeCategory
  , ptiArrayOid :: Word32
  -- ^ OID of the array form of this type (0 if none).
  , ptiBaseOid :: Word32
  -- ^ For domains: the underlying type OID. 0 otherwise.
  , ptiElemOid :: Word32
  -- ^ For array types: the element type OID. 0 otherwise.
  }
  deriving stock (Show)

-- | Query @pg_type@ for information about an unknown OID.
queryTypeInfo :: Connection -> Oid -> IO (Maybe PgTypeInfo)
queryTypeInfo conn (Oid rawOid) = do
  let sql =
        "SELECT typname, typtype, typarray, typbasetype, typelem FROM pg_type WHERE oid = "
          <> BS8.pack (show rawOid)
  result <- try @PgWireError (simpleQuery conn sql)
  pure $ case result of
    Left _ -> Nothing
    Right (rows, _) -> case rows of
      (row : _) -> Just (parsePgTypeRow row)
      [] -> Nothing

parsePgTypeRow :: [Maybe ByteString] -> PgTypeInfo
parsePgTypeRow row =
  let (mName, mTyptype, mTyparray, mTypbase, mTypelem) = case row of
        (a : b : c : d : e : _) -> (a, b, c, d, e)
        _ -> (Nothing, Nothing, Nothing, Nothing, Nothing)
   in PgTypeInfo
        { ptiName = maybe "" TE.decodeUtf8 mName
        , ptiCategory = parseTyptype mTyptype
        , ptiArrayOid = parseOidField mTyparray
        , ptiBaseOid = parseOidField mTypbase
        , ptiElemOid = parseOidField mTypelem
        }

parseTyptype :: Maybe ByteString -> PgTypeCategory
parseTyptype (Just "e") = PgEnum
parseTyptype (Just "c") = PgComposite
parseTyptype (Just "d") = PgDomain
parseTyptype (Just "r") = PgRange
parseTyptype (Just "p") = PgPseudo
parseTyptype _ = PgBase

parseOidField :: Maybe ByteString -> Word32
parseOidField Nothing = 0
parseOidField (Just bs) =
  case BS8.readInt bs of
    Just (n, _) -> fromIntegral n
    Nothing -> 0

-- | Query @pg_enum@ for the labels of an enum type.
queryEnumLabels :: Connection -> Oid -> IO [Text]
queryEnumLabels conn (Oid rawOid) = do
  let sql =
        "SELECT enumlabel FROM pg_enum WHERE enumtypid = "
          <> BS8.pack (show rawOid)
          <> " ORDER BY enumsortorder"
  result <- try @PgWireError (simpleQuery conn sql)
  pure $ case result of
    Left _ -> []
    Right (rows, _) -> map labelOf rows
  where
    labelOf (Just v : _) = TE.decodeUtf8 v
    labelOf _ = ""

-- | Query @pg_range@ for the subtype OID of a range type.
queryRangeSubtype :: Connection -> Oid -> IO (Maybe Oid)
queryRangeSubtype conn (Oid rawOid) = do
  let sql =
        "SELECT rngsubtype FROM pg_range WHERE rngtypid = "
          <> BS8.pack (show rawOid)
  result <- try @PgWireError (simpleQuery conn sql)
  pure $ case result of
    Left _ -> Nothing
    Right (rows, _) -> case rows of
      ((Just v : _) : _) -> case BS8.readInt v of
        Just (n, _) -> Just (Oid (fromIntegral n))
        Nothing -> Nothing
      _ -> Nothing