packages feed

psql-0.0.0: lib/PostgreSQL/Statement.hs

{-# LANGUAGE DeriveAnyClass #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE DerivingStrategies #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE QuasiQuotes #-}
{-# LANGUAGE RankNTypes #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE StrictData #-}
{-# LANGUAGE TemplateHaskell #-}
{-# LANGUAGE TypeApplications #-}
{-# LANGUAGE TypeFamilies #-}
{-# LANGUAGE UndecidableInstances #-}

-- | Tools to deal with templates and statements are defined here.
module PostgreSQL.Statement
  ( Template
  , code
  , identifier
  , string
  , param
  , paramWith
  , constant

  , Statement (..)
  , renderTemplate

  , PreparedStatement (..)

  , tpl
  , stmt
  )
where

import           Control.Applicative ((<|>))
import           Control.Monad (join)
import qualified Control.Monad.State.Strict as State
import qualified Crypto.Hash as Hash
import           Data.ByteString (ByteString)
import qualified Data.ByteString.Char8 as ByteString.Char8
import           Data.Char (isAlphaNum)
import           Data.Foldable (asum, fold)
import           Data.Functor.Contravariant (Contravariant (..))
import qualified Data.Sequence as Sequence
import           Data.String (IsString (..))
import           Data.Text (Text)
import qualified Data.Text as Text
import           Data.Text.Encoding (encodeUtf8)
import           Data.Traversable (for)
import           Data.Void (Void)
import           GHC.OverloadedLabels (IsLabel (..))
import           GHC.Records (HasField (..))
import qualified Language.Haskell.TH as TH
import qualified Language.Haskell.TH.Quote as Quote
import           Numeric.Natural (Natural)
import qualified PostgreSQL.Param as Param
import           PostgreSQL.Types (Oid)
import qualified Text.Megaparsec as Megaparsec
import qualified Text.Megaparsec.Char as Megaparsec.Char

data Segment a
  = Parameter (Param.Info (a -> Param.Value))
  | Code Text

instance Contravariant Segment where
  contramap f = \case
    Parameter g -> Parameter $ fmap (. f) g
    Code text -> Code text

  {-# INLINE contramap #-}

-- | SQL statement template
--
-- @since 0.0.0
newtype Template a = Template
  { _unStatement :: Sequence.Seq (Segment a) }
  deriving newtype
    ( Semigroup -- ^ @since 0.0.0
    , Monoid -- ^ @since 0.0.0
    )

-- | @since 0.0.0
instance Contravariant Template where
  contramap f (Template seqs) = Template (fmap (contramap f) seqs)

  {-# INLINE contramap #-}

-- | @OverloadedStrings@ helper for 'code'
--
-- > "my code" === code "my code"
--
-- @since 0.0.0
instance IsString (Template a) where
  fromString = code . Text.pack

  {-# INLINE fromString #-}

-- | @OverloadedLabels@ helper for 'param'
--
-- > #myParam === param (getField @"myParam")
--
-- Use this with a database:
--
-- > data MyFoo = MyFoo { bar :: Int, baz :: String }
-- >
-- > myStatementTpl :: Template MyFoo
-- > myStatementTpl = "SELECT * FROM my_foo WHERE bar = " <> #bar <> " AND baz = " <> #baz
--
--
-- @since 0.0.0
instance (HasField n r a, Param.Param a) => IsLabel n (Template r) where
  fromLabel = param (getField @n @r @a)

  {-# INLINE fromLabel #-}

-- | Create a code-only statement.
--
-- @since 0.0.0
code :: Text -> Template a
code = Template . Sequence.singleton . Code

{-# INLINE code #-}

-- | Create a code segment that mentions the given identifier (e.g. table or column name).
--
-- @since 0.0.0
identifier :: Text -> Template a
identifier name =
  code $ Text.concat ["\"", safeName, "\""]
  where
    safeName = Text.intercalate "\"\"" $ Text.split (== '"') name

{-# INLINE identifier #-}

-- | Encase the given string literal in single quotes. Single quotes in the literal are
-- automatically escaped.
--
-- @since 0.0.0
string :: Text -> Template a
string str = "'" <> code (Text.replace "'" "''" str) <> "'"

{-# INLINE string #-}

-- | Annotate the given statement with a type signature.
annotateParamType :: Maybe Text -> Template a -> Template a
annotateParamType typeAnnotation stmt =
  case typeAnnotation of
    Just paramType -> "(" <> stmt <> code (" :: " <> paramType <> ")")
    Nothing -> stmt

{-# INLINE annotateParamType #-}

-- | Reference a parameter.
--
-- @since 0.0.0
param :: forall b a. Param.Param b => (a -> b) -> Template a
param f = paramWith $ fmap (. f) $ Param.paramInfo @b

{-# INLINE param #-}

-- | Reference a parameter.
--
-- @since 0.0.0
paramWith :: Param.Info (a -> Param.Value) -> Template a
paramWith info =
  annotateParamType (Param.info_typeName info) $ Template $ Sequence.singleton $ Parameter info

{-# INLINE paramWith #-}

-- | Constant part of a query.
--
-- @since 0.0.0
constant :: forall b a. Param.Param b => b -> Template a
constant x = paramWith $ fmap (. const x) $ Param.paramInfo @b

{-# INLINE constant #-}

-- | Rendered SQL statement
--
-- @since 0.0.0
data Statement a = Statement
  { statement_code :: ByteString
  , statement_mkParams :: a -> [Param.PackedParam]
  , statement_types :: [Oid]
  , statement_name :: ByteString
  }

-- | @since 0.0.0
instance Contravariant Statement where
  contramap f statement = Statement
    { statement_code = statement_code statement
    , statement_mkParams = statement_mkParams statement . f
    , statement_types = statement_types statement
    , statement_name = statement_name statement
    }

  {-# INLINE contramap #-}

-- | Render the SQL statement.
--
-- @since 0.0.0
renderTemplate :: Template a -> Statement a
renderTemplate (Template segments :: Template a) = Statement
  { statement_code = codeBytes
  , statement_mkParams = mkParams
  , statement_types = types
  , statement_name = ByteString.Char8.pack (show hash)
  }
  where
    code :: Text
    code = fold $ flip State.evalState (1 :: Natural) $ for segments $ \case
      Parameter _ -> do
        index <- State.state $ \i -> (i, i + 1)
        pure $ Text.pack $ '$' : show index

      Code text ->
        pure text

    codeBytes :: ByteString
    codeBytes = encodeUtf8 code

    mkParams :: a -> [Param.PackedParam]
    mkParams input =
      foldr
        (\case
          Parameter info -> (Param.packParam (fmap ($ input) info) :)
          Code{} -> id
        )
        []
        segments

    types :: [Oid]
    types =
      foldr
        (\case
          Parameter info -> (Param.typeOid (Param.info_type info) :)
          Code{} -> id
        )
        []
        segments

    hash :: Hash.Digest Hash.SHA224
    hash =
      Hash.hashFinalize $ Hash.hashUpdates Hash.hashInit $
        codeBytes : map (ByteString.Char8.pack . show) types

{-# INLINE renderTemplate #-}

---

-- | Prepared statement
--
-- @since 0.0.0
data PreparedStatement a = PreparedStatement
  { preparedStatement_name :: ByteString
  , preparedStatement_mkParams :: a -> [Param.PackedParamPrepared]
  }

-- | @since 0.0.0
instance Contravariant PreparedStatement where
  contramap f statement = PreparedStatement
    { preparedStatement_name = preparedStatement_name statement
    , preparedStatement_mkParams = preparedStatement_mkParams statement . f
    }

---

parseName :: Megaparsec.Parsec Void String String
parseName =
  Megaparsec.takeWhile1P Nothing $ \c ->
    isAlphaNum c || elem @[] c "_'"

data QuoteSegment
  = QuoteCode String
  | QuoteParam String
  | QuoteSubst String

parseQuote :: Megaparsec.Parsec Void String (TH.Q TH.Exp)
parseQuote =
  combine <$> Megaparsec.many (asum [nonSegment, dollar, interactive])
  where
    nonSegment = QuoteCode <$> Megaparsec.takeWhile1P Nothing (/= '$')

    dollar = QuoteCode "$" <$ Megaparsec.Char.string "$$"

    between lhs inner rhs =
      Megaparsec.between (Megaparsec.Char.char lhs) (Megaparsec.Char.char rhs) inner

    interactive = do
      _ <- Megaparsec.Char.char '$'
      asum
        [ QuoteSubst <$> between '(' parseName ')'
        , QuoteParam <$> (between '{' parseName '}' <|> parseName)
        ]

    combine segments = do
      segments <- for segments $ pure . \case
        QuoteCode code ->
          [e| fromString $(TH.stringE code) |]

        QuoteParam paramCode ->
          integrateAsParam paramCode

        QuoteSubst paramCode ->
          integrateAsSubst paramCode

      [e| mconcat $(TH.listE segments) |]

integrateAsParam :: String -> TH.ExpQ
integrateAsParam paramCode =
  [e| PostgreSQL.Statement.param $(TH.varE (TH.mkName paramCode)) |]

integrateAsSubst :: String -> TH.ExpQ
integrateAsSubst paramCode =
  TH.varE (TH.mkName paramCode)

tplQuoteExp :: String -> TH.Q TH.Exp
tplQuoteExp contents = do
  join $ either (fail . Megaparsec.errorBundlePretty) pure $
    Megaparsec.parse
      (parseQuote <* Megaparsec.eof)
      "(PostgreSQL.Statement.tpl quasi-quotation)"
      contents

-- | Produces a 'Template' expression.
--
-- Supports the same features as 'stmt'.
--
-- @since 0.0.0
tpl :: Quote.QuasiQuoter
tpl = Quote.QuasiQuoter
  { Quote.quoteExp = tplQuoteExp
  , Quote.quotePat = error "'tpl' cannot be used in a pattern"
  , Quote.quoteType = error "'tpl' cannot be used in a type"
  , Quote.quoteDec = error "'tpl' cannot be used in a declaration"
  }

stmtQuoteExp :: String -> TH.Q TH.Exp
stmtQuoteExp contents = do
  stmt <- either (fail . Megaparsec.errorBundlePretty) pure $
    Megaparsec.parse
      (parseQuote <* Megaparsec.eof)
      "(PostgreSQL.Statement.stmt quasi-quotation)"
      contents
  [e| renderTemplate $stmt |]

-- | Produces a 'Statement' expression.
--
-- > [stmt| SELECT $param * 2 |]
--
-- Use @$$@ to render a single @$@.
--
-- == Parameters
--
-- Use @$param@ or @${param}@ to reference a query parameter.
--
-- @[stmt| ${x} |]@ is equivalent to @'param' x@.
--
-- == Substitutions
--
-- Use @$(substr)@ to embed another 'Template' where @substr :: 'Template' a@.
--
-- @[stmt| $(x) |]@ is equivalent to @x@.
--
-- == Examples
--
-- > data MyParams = MyParams { foo :: Int, bar :: Text }
-- >
-- > myStatement :: Statement MyParams
-- > myStatement = [stmt| SELECT baz FROM my_table WHERE foo > ${foo} AND bar = ${bar} |]
--
-- @since 0.0.0
stmt :: Quote.QuasiQuoter
stmt = Quote.QuasiQuoter
  { Quote.quoteExp = stmtQuoteExp
  , Quote.quotePat = error "'stmt' cannot be used in a pattern"
  , Quote.quoteType = error "'stmt' cannot be used in a type"
  , Quote.quoteDec = error "'stmt' cannot be used in a declaration"
  }