hpgsql-0.1.0.0: src/Hpgsql/QueryInternal.hs
module Hpgsql.QueryInternal
( Query (..),
SingleQuery (..),
sql,
sqlPrep,
mkQueryInternal,
breakQueryIntoStatements,
mkQuery,
encodeParam,
)
where
import Data.ByteString (ByteString)
import Data.Either (rights)
import qualified Data.List as List
import qualified Data.Map.Strict as Map
import Data.Text (Text)
import qualified Data.Text as Text
import Data.Text.Encoding (decodeUtf8, encodeUtf8)
import Hpgsql.Builder (BinaryField)
import Hpgsql.Encoding (FieldEncoder (..), RowEncoder (..), ToPgField (..), ToPgRow (..))
import Hpgsql.InternalTypes (Query (..), SingleQuery (..), SingleQueryFragment (..), breakQueryIntoStatements, renumberParamsFrom)
import Hpgsql.ParsingInternal (BlockOrNotBlock (..), ParsingOpts (..), QQExprKind (..), blockText, flattenBlocks, parseSql)
import Hpgsql.TypeInfo (EncodingContext, Oid)
import Language.Haskell.Meta.Parse (parseExp)
import Language.Haskell.TH
import Language.Haskell.TH.Quote
-- | A useful representation for our quasiquoter parsing.
data SqlFragment
= NonInterpolatedSqlFragment !Text
| InterpolatedHaskellExpr !Text
| EmbeddedQueryExpr !Text
| SemiColonFragment
| WhitespaceOrCommentsFragment !Text
deriving stock (Eq)
-- | Takes in a query string with query arguments as dollar-numbered arguments and returns a Query you
-- can run.
-- Examples:
-- - "SELECT * FROM table WHERE col1=$1 AND col2=$2"
-- - "SELECT * FROM table WHERE (["a", "b", "c"]'::jsonb ? 'b') = $1"
-- Question marks are interpreted literally, i.e. they have no special meaning.
-- Note that if the number of arguments does not match what's in the query string,
-- this will throw an error.
mkQuery :: (ToPgRow a) => ByteString -> a -> Query
mkQuery qryText p = mkQueryInternalFromSqlStatements (parseSql AcceptOnlyDollarNumberedArgs $ decodeUtf8 qryText) (rowEncoder.toPgParams p)
where
mkQueryInternalFromSqlStatements :: [BlockOrNotBlock] -> [EncodingContext -> (Maybe Oid, BinaryField)] -> Query
mkQueryInternalFromSqlStatements blocks allParams =
let paramsLen = length allParams
qryTextForError = show $ mconcat $ map blockText blocks
queryFrags =
map
( \case
StaticSql t -> FragmentOfStaticSql $ encodeUtf8 t
SemiColon -> FragmentWithSemiColon
CommentsOrWhitespace t -> FragmentOfCommentsOrWhitespace $ encodeUtf8 t
DollarNumberedArg n ->
if n < 1
then
error $ "Dollar-numbered query argument placeholders must start from 1. Query: " ++ qryTextForError
else
if n > paramsLen
then
error $ "Query contains more dollar-numbered query arguments than actually supplied query arguments. Query: " ++ qryTextForError
else QueryArgumentPlaceHolder n
QuestionMarkArg ->
error $ "Bug in Hpgsql: parseSql AcceptOnlyDollarNumberedArgs returned question mark place holders. Query: " ++ qryTextForError
QuasiQuoterExpression _ _ ->
error $ "Bug in Hpgsql: parseSql AcceptOnlyDollarNumberedArgs returned quasiquoter expression. Query: " ++ qryTextForError
)
blocks
in Query {queryString = queryFrags, queryParams = allParams, isPrepared = False}
-- | Takes in a query string with query arguments as question marks (NOT dollar-numbered arguments).
-- Example:
-- - "SELECT * FROM table WHERE col1=? AND col2=?"
-- You should really use `mkQuery` instead of this. This is here only for hpgsql-simple-compat.
mkQueryInternal :: ByteString -> [[Either ByteString (EncodingContext -> (Maybe Oid, BinaryField))]] -> Query
mkQueryInternal queryTemplate allParams =
let statements = parseSql AcceptQuestionMarksAsQueryArgs (decodeUtf8 queryTemplate)
paramsByIdx = Map.fromList $ zip [(1 :: Int) ..] allParams
qryTextForError = mconcat $ map blockText statements
(_, queryFrags) =
List.mapAccumL
( \(!maxArgSoFar, !maxRealArgSoFar) sqlPiece -> case sqlPiece of
StaticSql t -> ((maxArgSoFar, maxRealArgSoFar), [FragmentOfStaticSql $ encodeUtf8 t])
SemiColon -> ((maxArgSoFar, maxRealArgSoFar), [FragmentWithSemiColon])
CommentsOrWhitespace t -> ((maxArgSoFar, maxRealArgSoFar), [FragmentOfCommentsOrWhitespace $ encodeUtf8 t])
DollarNumberedArg _ ->
error $ "Bug in Hpgsql: parsed a DollarNumberedArg in mkQueryInternal. Query: " ++ show (queryTemplate, qryTextForError)
QuasiQuoterExpression _ _ ->
error $ "Bug in Hpgsql: parsed a QuasiQuoterExpression in mkQueryInternal. Query: " ++ show (queryTemplate, qryTextForError)
QuestionMarkArg ->
let thisParamNum = maxArgSoFar + 1
in case Map.lookup thisParamNum paramsByIdx of
Nothing -> error $ "Could not find query argument of number " <> show thisParamNum <> " for query with " <> show (length allParams) <> " arguments supplied. Did you supply an insufficient amount of query arguments? Query is " ++ show qryTextForError
Just args ->
let (newMaxRealArg, frags) =
List.mapAccumL
( \(!newArgNum) arg -> case arg of
Left fakeArg ->
(newArgNum, FragmentOfStaticSql fakeArg)
Right _properArg ->
(newArgNum + 1, QueryArgumentPlaceHolder $ newArgNum + 1)
)
maxRealArgSoFar
args
in ((thisParamNum, newMaxRealArg), frags)
)
(0, 0)
statements
in Query {queryString = mconcat queryFrags, queryParams = concatMap rights allParams, isPrepared = False}
-- A practical quasiquoter for SQL. Interpolate Haskell values with @#{}@:
--
-- > let name = "Alice" :: Text
-- > rows <- query conn [sql|SELECT id, email FROM users WHERE name = #{name}|]
-- > print (rows :: [(Int, Text)])
--
-- Values interpolated with @#{}@ are sent as query parameters, so they are safe from SQL injection.
--
-- To embed a 'Query' fragment (e.g. a dynamic table name or a sub-query), use @^{}@:
--
-- > let tableName = escapeIdentifier "users"
-- > rows <- query conn [sql|SELECT * FROM ^{tableName}|]
sql :: QuasiQuoter
sql =
QuasiQuoter
{ quoteExp = liftQuery False . parseSql AcceptQuasiQuoterExpressions . Text.pack,
quotePat = error "Hpgsql's sql quasiquoter does not implement quotePat",
quoteType = error "Hpgsql's sql quasiquoter does not implement quoteType",
quoteDec = error "Hpgsql's sql quasiquoter does not implement quoteDec"
}
-- | Just like the @sql@ quasiquoter, but every SQL statement inside is
-- a prepared statement.
sqlPrep :: QuasiQuoter
sqlPrep =
QuasiQuoter
{ quoteExp = liftQuery True . parseSql AcceptQuasiQuoterExpressions . Text.pack,
quotePat = error "Hpgsql's sql quasiquoter does not implement quotePat",
quoteType = error "Hpgsql's sql quasiquoter does not implement quoteType",
quoteDec = error "Hpgsql's sql quasiquoter does not implement quoteDec"
}
liftQuery :: Bool -> [BlockOrNotBlock] -> Q Exp
liftQuery isPrepared (flattenBlocks -> stmt) = do
let allFragments = concatMap parseBlockQuasiQuoter stmt
hasEmbedded = any isEmbedded allFragments
if hasEmbedded
then liftQueryDynamic isPrepared allFragments
else liftQueryStatic isPrepared allFragments
where
isEmbedded (EmbeddedQueryExpr _) = True
isEmbedded _ = False
-- | Static path: no ^{} expressions, SQL string is a compile-time literal.
liftQueryStatic :: Bool -> [SqlFragment] -> Q Exp
liftQueryStatic isPrepared allFragments = do
let (fragQExps, varNames) = buildQueryFragsAndVars allFragments 1
fragExps <- sequence fragQExps
paramExps <- mapM generateParamExp varNames
[|Query $(pure $ ListE fragExps) $(pure $ ListE paramExps) isPrepared|]
-- | Dynamic path: has ^{} expressions, build query at runtime via buildQueryQQ.
liftQueryDynamic :: Bool -> [SqlFragment] -> Q Exp
liftQueryDynamic isPrepared allFragments = do
partExps <- mapM fragmentToPartExp allFragments
[|buildQueryQQ isPrepared $(pure $ ListE partExps)|]
-- | Convert a SqlFragment into a TH expression producing a QueryBuildPart.
fragmentToPartExp :: SqlFragment -> Q Exp
fragmentToPartExp (NonInterpolatedSqlFragment t) =
[|StaticSqlPart $(litE (stringL (Text.unpack t)))|]
fragmentToPartExp (InterpolatedHaskellExpr haskellExpr) =
case parseExp (Text.unpack haskellExpr) of
Left err -> error $ "Could not parse Haskell expression '" ++ Text.unpack haskellExpr ++ "': " ++ err
Right expr -> [|ParamPart (encodeParam $(pure expr))|]
fragmentToPartExp SemiColonFragment =
[|SemiColonPart|]
fragmentToPartExp (WhitespaceOrCommentsFragment t) =
[|WhitespaceOrCommenstPart $(litE (stringL (Text.unpack t)))|]
fragmentToPartExp (EmbeddedQueryExpr haskellExpr) =
case parseExp (Text.unpack haskellExpr) of
Left err -> error $ "Could not parse Haskell expression '" ++ Text.unpack haskellExpr ++ "': " ++ err
Right expr -> [|EmbeddedQueryPart $(pure expr)|]
-- | Walk through fragments in order, building SingleQueryFragment TH expressions
-- and collecting the interpolated Haskell expressions.
buildQueryFragsAndVars :: [SqlFragment] -> Int -> ([Q Exp], [Text])
buildQueryFragsAndVars [] _ = ([], [])
buildQueryFragsAndVars (NonInterpolatedSqlFragment t : rest) n =
let (restFrags, restVars) = buildQueryFragsAndVars rest n
in ([|FragmentOfStaticSql (encodeUtf8 $(litE (stringL (Text.unpack t))))|] : restFrags, restVars)
buildQueryFragsAndVars (WhitespaceOrCommentsFragment t : rest) n =
let (restFrags, restVars) = buildQueryFragsAndVars rest n
in ([|FragmentOfCommentsOrWhitespace (encodeUtf8 $(litE (stringL (Text.unpack t))))|] : restFrags, restVars)
buildQueryFragsAndVars (InterpolatedHaskellExpr var : rest) n =
let (restFrags, restVars) = buildQueryFragsAndVars rest (n + 1)
in ([|QueryArgumentPlaceHolder $(litE (integerL (fromIntegral n)))|] : restFrags, var : restVars)
buildQueryFragsAndVars (SemiColonFragment : rest) n =
let (restFrags, restVars) = buildQueryFragsAndVars rest n
in ([|FragmentWithSemiColon|] : restFrags, restVars)
buildQueryFragsAndVars (EmbeddedQueryExpr _ : _) _ =
error "Bug in Hpgsql: EmbeddedQueryExpr should not appear in static path"
-- | Parts used to build a SingleQuery at runtime when ^{} embedded queries are present.
data QueryBuildPartQQ
= StaticSqlPart !ByteString
| ParamPart !(EncodingContext -> (Maybe Oid, BinaryField))
| EmbeddedQueryPart !Query
| SemiColonPart
| WhitespaceOrCommenstPart !ByteString
buildQueryQQ :: Bool -> [QueryBuildPartQQ] -> Query
buildQueryQQ isPrepared parts =
let (queryFrags, allParams) = go parts 1
in Query {queryString = queryFrags, queryParams = allParams, isPrepared}
where
go [] _ = ([], [])
go (part : rest) argNum = case part of
StaticSqlPart bs ->
let (restFrags, restParams) = go rest argNum
in (FragmentOfStaticSql bs : restFrags, restParams)
WhitespaceOrCommenstPart bs ->
let (restFrags, restParams) = go rest argNum
in (FragmentOfCommentsOrWhitespace bs : restFrags, restParams)
ParamPart p ->
let (restFrags, restParams) = go rest (argNum + 1)
in (QueryArgumentPlaceHolder argNum : restFrags, p : restParams)
SemiColonPart ->
let (restFrags, restParams) = go rest argNum
in (FragmentWithSemiColon : restFrags, restParams)
EmbeddedQueryPart q ->
let (newMaxArgNum, renumberedFrags) = renumberParamsFrom q.queryString argNum
(restFrags, restParams) = go rest (newMaxArgNum + 1)
in (renumberedFrags ++ restFrags, q.queryParams ++ restParams)
parseBlockQuasiQuoter :: BlockOrNotBlock -> [SqlFragment]
parseBlockQuasiQuoter (StaticSql text) = [NonInterpolatedSqlFragment text]
parseBlockQuasiQuoter SemiColon = [SemiColonFragment]
parseBlockQuasiQuoter (CommentsOrWhitespace text) = [WhitespaceOrCommentsFragment text]
parseBlockQuasiQuoter (DollarNumberedArg n) = [NonInterpolatedSqlFragment $ "$" <> Text.pack (show n)] -- If someone uses e.g. $2 in a quasiquoter, it should be an error, but let's assume they know what they're doing and treat it as text
parseBlockQuasiQuoter QuestionMarkArg = [NonInterpolatedSqlFragment "?"] -- If someone uses a question mark in a quasiquoter, it should be an error, but let's assume they know what they're doing and treat it as text
parseBlockQuasiQuoter (QuasiQuoterExpression QQInterpolation expr) = [InterpolatedHaskellExpr expr]
parseBlockQuasiQuoter (QuasiQuoterExpression QQEmbeddedQuery expr) = [EmbeddedQueryExpr expr]
-- | Generate a parameter expression for a captured variable
generateParamExp :: Text -> Q Exp
generateParamExp (Text.unpack -> haskellExpr) =
case parseExp haskellExpr of
Left err -> error $ "Could not parse Haskell expression '" ++ haskellExpr ++ "': " ++ err
Right expr ->
[|encodeParam $(pure expr)|]
encodeParam :: (ToPgField a) => a -> EncodingContext -> (Maybe Oid, BinaryField)
encodeParam v =
let fe = fieldEncoder
in \encCtx -> (fe.toTypeOid encCtx, fe.toPgField encCtx v)