packages feed

hasql-generate-1.0.0: src/Hasql/Generate/Internal/Introspect.hs

module Hasql.Generate.Internal.Introspect
    ( ColumnInfo (..)
    , introspectColumns
    , introspectEnumLabels
    , introspectPrimaryKey
    ) where

----------------------------------------------------------------------------------------------------

import           Control.Exception         ( throwIO )
import           Control.Monad             ( return )

import           Data.Bool                 ( Bool (..), otherwise )
import qualified Data.ByteString           as BS
import qualified Data.ByteString.Char8     as BS8
import           Data.Eq                   ( (==) )
import           Data.Function             ( ($) )
import           Data.Functor              ( (<$>) )
import           Data.Maybe                ( Maybe (..), maybe )
import           Data.Semigroup            ( (<>) )
import           Data.String               ( String )

import qualified Database.PostgreSQL.LibPQ as PQ

import           Prelude
    ( Applicative (pure)
    , userError
    , (+)
    )

import           System.IO                 ( IO )

----------------------------------------------------------------------------------------------------

data ColumnInfo
    = ColumnInfo
      { colName       :: String
      , colPgSchema   :: String
      , colPgType     :: String
      , colIsEnum     :: Bool
      , colNotNull    :: Bool
      , colHasDefault :: Bool
      }

----------------------------------------------------------------------------------------------------

{-  Query @pg_catalog@ for all visible user columns of the given table, including
    both base types (@typtype = 'b'@) and enum types (@typtype = 'e'@), excluding
    composite, pseudo, and range types. Results are ordered by column position.
-}
introspectColumns :: PQ.Connection -> String -> String -> IO [ColumnInfo]
introspectColumns conn schema table = do
  result <- PQ.execParams conn columnSQL [textParam schema, textParam table] PQ.Text
  withQueryResult "column" result parseColumnRows

----------------------------------------------------------------------------------------------------

{-  Query @pg_catalog@ for the primary key column names of the given table,
    ordered by their position within the index.
-}
introspectPrimaryKey :: PQ.Connection -> String -> String -> IO [String]
introspectPrimaryKey conn schema table = do
  result <- PQ.execParams conn primaryKeySQL [textParam schema, textParam table] PQ.Text
  withQueryResult "primary key" result parsePkRows

----------------------------------------------------------------------------------------------------

{-  Query @pg_catalog@ for the labels of a PostgreSQL enum type in the given
    schema, ordered by their sort position (@enumsortorder@).
-}
introspectEnumLabels :: PQ.Connection -> String -> String -> IO [String]
introspectEnumLabels conn schema typeName = do
  result <- PQ.execParams conn enumLabelSQL [textParam schema, textParam typeName] PQ.Text
  withQueryResult "enum label" result parseEnumRows

----------------------------------------------------------------------------------------------------

columnSQL :: BS.ByteString
columnSQL =
  "SELECT tn.nspname, a.attname, t.typname, t.typtype, a.attnotnull, a.atthasdef \
  \FROM pg_catalog.pg_attribute a \
  \JOIN pg_catalog.pg_class c ON c.oid = a.attrelid \
  \JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace \
  \JOIN pg_catalog.pg_type t ON t.oid = a.atttypid \
  \JOIN pg_catalog.pg_namespace tn ON tn.oid = t.typnamespace \
  \WHERE n.nspname = $1 \
  \  AND c.relname = $2 \
  \  AND a.attnum > 0 \
  \  AND NOT a.attisdropped \
  \  AND t.typtype IN ('b', 'e') \
  \  AND t.typcategory NOT IN ('C', 'P', 'X') \
  \ORDER BY a.attnum"

primaryKeySQL :: BS.ByteString
primaryKeySQL =
  "SELECT a.attname \
  \FROM pg_catalog.pg_index i \
  \JOIN pg_catalog.pg_class c ON c.oid = i.indrelid \
  \JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace \
  \JOIN pg_catalog.pg_attribute a ON a.attrelid = i.indrelid \
  \  AND a.attnum = ANY(i.indkey) \
  \WHERE n.nspname = $1 \
  \  AND c.relname = $2 \
  \  AND i.indisprimary \
  \ORDER BY array_position(i.indkey, a.attnum)"

enumLabelSQL :: BS.ByteString
enumLabelSQL =
  "SELECT e.enumlabel \
  \FROM pg_catalog.pg_enum e \
  \JOIN pg_catalog.pg_type t ON t.oid = e.enumtypid \
  \JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace \
  \WHERE n.nspname = $1 \
  \  AND t.typname = $2 \
  \ORDER BY e.enumsortorder"

----------------------------------------------------------------------------------------------------

parseColumnRows :: PQ.Result -> IO [ColumnInfo]
parseColumnRows res = do
  nRows <- PQ.ntuples res
  mapRows nRows $ \row -> do
    schemaVal <- PQ.getvalue res row 0
    nameVal <- PQ.getvalue res row 1
    typeVal <- PQ.getvalue res row 2
    typtypeVal <- PQ.getvalue res row 3
    notNullVal <- PQ.getvalue res row 4
    defVal <- PQ.getvalue res row 5
    let schema = maybe "" BS8.unpack schemaVal
        name = maybe "" BS8.unpack nameVal
        typ = maybe "" BS8.unpack typeVal
        isEnum = Just "e" == typtypeVal
        notNull = maybe False parseBool notNullVal
        hasDef = maybe False parseBool defVal
    return (ColumnInfo name schema typ isEnum notNull hasDef)

parsePkRows :: PQ.Result -> IO [String]
parsePkRows res = do
  nRows <- PQ.ntuples res
  mapRows nRows $ \row -> do
    nameVal <- PQ.getvalue res row 0
    return (maybe "" BS8.unpack nameVal)

parseEnumRows :: PQ.Result -> IO [String]
parseEnumRows res = do
  nRows <- PQ.ntuples res
  mapRows nRows $ \row -> do
    labelVal <- PQ.getvalue res row 0
    return (maybe "" BS8.unpack labelVal)

parseBool :: BS.ByteString -> Bool
parseBool bs
  | bs == "t" = True
  | otherwise = False

----------------------------------------------------------------------------------------------------

-- | Wrap a 'String' as a libpq text parameter (oid 25 = @text@).
textParam :: String -> Maybe (PQ.Oid, BS.ByteString, PQ.Format)
textParam str = Just (PQ.Oid 25, BS8.pack str, PQ.Text)

{-  Unwrap and validate a libpq query result, passing it to a row-parser on
    success. The @label@ argument names the introspection step for error
    messages.
-}
withQueryResult :: String -> Maybe PQ.Result -> (PQ.Result -> IO a) -> IO a
withQueryResult label result parse = case result of
  Nothing -> throwIO (userError ("hasql-generate: " <> label <> " introspection query returned no result"))
  Just res -> do
    status <- PQ.resultStatus res
    if status == PQ.TuplesOk
      then parse res
      else do
        msg <- maybe "unknown error" BS8.unpack <$> PQ.resultErrorMessage res
        throwIO (userError ("hasql-generate: " <> label <> " introspection failed: " <> msg))

-- |  Iterate over row indices [0 .. nRows-1] collecting results.
mapRows :: PQ.Row -> (PQ.Row -> IO a) -> IO [a]
mapRows nRows fn = go (PQ.Row 0)
  where
    go i
      | i == nRows = pure []
      | otherwise = do
          x <- fn i
          xs <- go (nextRow i)
          pure (x : xs)

    nextRow :: PQ.Row -> PQ.Row
    nextRow (PQ.Row n) = PQ.Row (n + 1)