packages feed

hpgsql-0.2.0.0: src/Hpgsql/Connection.hs

module Hpgsql.Connection
  ( connect,
    connectOpts,
    defaultConnectOpts,
    withConnection,
    withConnectionOpts,
    closeGracefully,
    closeForcefully,
    connectionIsClosed,
    ConnectionString (..),
    parseLibpqConnectionString,
    ResetConnectionOpts (..),
    resetConnectionState,
    renderLibpqConnectionString,
    refreshTypeInfoCache,
    resetTypeInfoCache,
    getParameterStatus,
    getBackendPid,
  )
where

import Control.Applicative
  ( (<|>),
  )
import Control.Monad
  ( unless,
    void,
    when,
  )
import Control.Monad.Trans.Except (runExceptT, throwE)
import Data.Attoparsec.Text
  ( Parser,
    char,
    endOfInput,
    parseOnly,
    peekChar,
    skipWhile,
    takeWhile1,
  )
import qualified Data.Attoparsec.Text as Parsec
import Data.ByteString (ByteString)
import qualified Data.ByteString as ByteString
import qualified Data.Char as Char
import Data.Functor.Identity (Identity (..))
import Data.List
  ( sortOn,
  )
import Data.Maybe (catMaybes)
import Data.Text (Text)
import qualified Data.Text as Text
import Data.Text.Encoding (encodeUtf8)
import Hpgsql.Internal (closeForcefully, closeGracefully, connect, connectOpts, connectionIsClosed, defaultConnectOpts, getBackendPid, getParameterStatus, refreshTypeInfoCache, resetConnectionState, resetTypeInfoCache, withConnection, withConnectionOpts)
import Hpgsql.InternalTypes (ConnectionString (..), ResetConnectionOpts (..))
import Network.URI
  ( URI (..),
    URIAuth (..),
    parseURI,
    unEscapeString,
  )
import Prelude hiding (takeWhile)

-- | Parses a libpq compatible connection string.
parseLibpqConnectionString :: Text -> Either String ConnectionString
parseLibpqConnectionString =
  parseOnly (connStringParser <* endOfInput)

-- | Renders a libpq compatible connection string.
renderLibpqConnectionString :: ConnectionString -> ByteString
renderLibpqConnectionString ConnectionString {..} =
  ByteString.intercalate " " $
    map (\(kw, v) -> kw <> "=" <> v) mixedKwvps
  where
    mixedKwvps =
      catMaybes
        [ Just ("user", quote user),
          Just
            ("host", quote hostname),
          Just
            ("dbname", quote database),
          Just ("password", quote password),
          Just ("port", quote (Text.pack $ show port)),
          if Text.null options then Nothing else Just ("options", quote options)
        ]
    quote un =
      encodeUtf8 $
        "'"
          <> Text.replace "'" "\\'" (Text.replace "\\" "\\\\" un)
          <> "'"

-- | Parser that consumes any kind of Unicode space character, including \t, \n, \r, \f, \v.
skipAllWhiteSpace :: Parser ()
skipAllWhiteSpace = skipWhile Char.isSpace

-- | Parses a value using backslash as an escape char for any char that matches
-- the supplied predicate. Stops at and does not consume the first predicate-passing
-- char, and does not include escape chars in the returned value,
-- as one would expect.
parseWithEscapeCharProper :: (Char -> Bool) -> Parser Text
parseWithEscapeCharProper untilc = do
  cs <- Parsec.takeWhile (\c -> c /= '\\' && not (untilc c))
  nextChar <- peekChar
  case nextChar of
    Nothing -> pure cs
    Just '\\' -> do
      void $ char '\\'
      c <- Parsec.take 1
      rest <- parseWithEscapeCharProper untilc
      pure $ cs <> c <> rest
    Just _ -> pure cs

eitherToMay :: Either a b -> Maybe b
eitherToMay (Left _) = Nothing
eitherToMay (Right v) = Just v

-- | Parses a URI with scheme 'postgres' or 'postgresql', as per https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNSTRING.
-- The difference here is that URIs with a query string or with a fragment are not allowed.
uriConnParser :: Text -> Either String ConnectionString
uriConnParser line = runIdentity $ runExceptT @String @_ @ConnectionString $ do
  case parseURI (Text.unpack line) of
    Nothing -> throwE "Connection string is not a URI"
    Just URI {..} -> do
      unless
        (Text.toLower (Text.pack uriScheme) `elem` ["postgres:", "postgresql:"])
        $ throwE
          "Connection string's URI scheme must be 'postgres' or 'postgresql'"
      case uriAuthority of
        Nothing ->
          throwE
            "Connection string must contain at least user and host"
        Just URIAuth {..} -> do
          let database =
                Text.pack $ unEscapeString $ trimFirst '/' uriPath
              hasQueryString = not $ null uriQuery
              hasFragment = not $ null uriFragment
          when (Text.null database) $
            throwE
              "Connection string must contain a database name"
          when (hasQueryString || hasFragment) $
            throwE
              "Custom parameters are not supported in connection strings. Make sure your connection URI does not have a query string or query fragment"

          -- Ports are not mandatory and are defaulted to 5432 when not present
          let port = if null uriPort then Just 5432 else Nothing
          case port
            <|> eitherToMay
              ( parseOnly
                  (Parsec.decimal <* endOfInput)
                  (Text.pack $ trimFirst ':' uriPort)
              ) of
            Nothing ->
              throwE "Invalid port in connection string"
            Just parsedPort -> do
              let (Text.pack . unEscapeString . trimLast '@' -> user, Text.pack . unEscapeString . trimLast '@' . trimFirst ':' -> password) =
                    break (== ':') uriUserInfo
              pure
                ConnectionString
                  { hostname =
                      Text.pack $
                        unEscapeString $
                          unescapeIPv6 uriRegName,
                    port = parsedPort,
                    user,
                    password,
                    database,
                    options = ""
                  }
  where
    unescapeIPv6 :: String -> String
    unescapeIPv6 = trimFirst '[' . trimLast ']'

    trimFirst :: Char -> String -> String
    trimFirst c s@(c1 : cs) = if c == c1 then cs else s
    trimFirst _ s = s

    trimLast :: Char -> String -> String
    trimLast c s = case Text.unsnoc $ Text.pack s of
      Nothing -> s
      Just (t, lastChar) -> if lastChar == c then Text.unpack t else s

keywordValueConnParser :: Text -> Either String ConnectionString
keywordValueConnParser line = runIdentity $ runExceptT $ do
  kvs <-
    sortOn fst
      <$> parseOrFail
        (singleKeyVal `Parsec.sepBy` takeWhile1 Char.isSpace)
        (Text.strip line)
        "Invalid connection string"
  ConnectionString
    <$> getVal "host" Nothing txtToString kvs
    <*> getVal "port" (Just 5432) Parsec.decimal kvs
    <*> getVal "user" Nothing txtToString kvs
    <*> getVal "password" (Just "") txtToString kvs
    <*> getVal "dbname" Nothing txtToString kvs
    <*> getVal "options" (Just "") txtToString kvs
  where
    getVal key def parser pairs =
      case (map snd $ filter ((== key) . fst) pairs, def) of
        ([], Nothing) ->
          throwE $
            "Connection string must contain a value for '"
              <> Text.unpack key
              <> "'"
        ([], Just v) -> pure v
        ([vt], _) ->
          parseOrFail parser vt $
            "Connection string key '"
              <> Text.unpack key
              <> "' is in an unrecognizable format"
        _ ->
          throwE $
            "Duplicate key '"
              <> Text.unpack key
              <> "' found in connection string."

    txtToString = Parsec.takeText
    parseOrFail parser txt errorMsg =
      case parseOnly (parser <* endOfInput) txt of
        Left _ -> throwE errorMsg
        Right v -> pure v

    singleKeyVal = do
      key <- takeWhile1 (\c -> not (Char.isSpace c) && c /= '=')
      skipAllWhiteSpace
      void $ char '='
      skipAllWhiteSpace
      value <-
        takeQuotedString
          <|> parseWithEscapeCharProper
            (\c -> Char.isSpace c || c == '\'' || c == '\\')
          <|> pure ""
      pure (key, value)

    takeQuotedString = do
      void $ char '\''
      s <- parseWithEscapeCharProper (== '\'')
      void $ char '\''
      pure s

-- | Parses a string in one of libpq allowed formats. See https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNSTRING.
-- The difference here is that only a subset of all connection parameters are allowed.
-- I wish this function existed in postgresql-simple or some form of it in postgresql-libpq, but if it does I couldn't find it.
connStringParser :: Parser ConnectionString
connStringParser = do
  connStr <-
    Parsec.takeWhile1 (const True)
      <|> fail "Empty connection string"
  -- Very poor connection string type handling here
  let connStrParser =
        if ("postgres://" `Text.isPrefixOf` Text.toLower connStr)
          || ("postgresql://" `Text.isPrefixOf` Text.toLower connStr)
          then
            uriConnParser
          else
            keywordValueConnParser
  case connStrParser connStr of
    Left err ->
      fail $
        "Connection string is not a valid libpq connection string. A valid libpq connection string is either in the format 'postgres://username[:password]@host:port/database_name', with URI-encoded (percent-encoded) components except for the host and bracket-surround IPv6 addresses, or in the keyword value pairs format, e.g. 'dbname=database_name host=localhost user=postgres' with escaping for spaces, quotes or empty values. More info at https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNSTRING. Specific error: "
          <> err
    Right c -> pure c