nri-redis-0.4.1.1: src/Redis/Script.hs
{-# LANGUAGE GADTs #-}
{-# LANGUAGE TemplateHaskell #-}
{-# LANGUAGE UndecidableInstances #-}
module Redis.Script
( Script (..),
script,
-- Internal API
luaScriptHash,
evalShaString,
scriptLoadString,
mapKeys,
-- For testing
parser,
Tokens (..),
ScriptParam (..),
HasScriptParam (..),
)
where
import qualified Control.Monad
import qualified Crypto.Hash.SHA1
import qualified Data.ByteString
import Data.Either (Either (..))
import qualified Data.Text
import qualified Data.Text.Encoding
import Data.Void (Void)
import qualified GHC.TypeLits
import Language.Haskell.Meta.Parse (parseExp)
import qualified Language.Haskell.TH as TH
import qualified Language.Haskell.TH.Quote as QQ
import Text.Megaparsec ((<|>))
import qualified Text.Megaparsec as P
import qualified Text.Megaparsec.Char as PC
import qualified Text.Printf
import Prelude (notElem, pure, (<*))
import qualified Prelude
data Script result = Script
{ -- | The Lua script to be executed with @args placeholders for Redis
luaScript :: Text,
-- | The script string as extracted from a `script` quasi quote.
quasiQuotedString :: Text,
keys :: [Text],
-- | The parameters that fill the placeholders in this query
arguments :: Log.Secret [Text]
}
deriving (Eq, Show)
-- | A type for enforcing parameters used in [script|${ ... }|] are either tagged as Key or Literal.
--
-- We need keys to be tagged, otherwise we can't implement `mapKeys` and enforce namespacing
-- in Redis APIs.
--
-- We make this extra generic to allow us to provide nice error messages using TypeError in a
-- type class below.
data ScriptParam
= forall a. (Show a) => Key a
| forall a. (Show a) => Literal a
class HasScriptParam a where
getScriptParam :: a -> ScriptParam
-- | This instance is marked as INCOHERENT so that it will be chosen if possible in the overlapping case
instance {-# INCOHERENT #-} HasScriptParam ScriptParam where
getScriptParam = Prelude.id
-- | This instance is used to provide a helpful error message when a user tries to use a type
-- other than a ScriptParam in a [script|${ ... }|] quasi quote.
--
-- It is what forces us to hav UndecidableInstances enabled.
instance
(GHC.TypeLits.TypeError ('GHC.TypeLits.Text "[script| ${..} ] interpolation only supports Key or Literal inputs.")) =>
HasScriptParam x
where
getScriptParam = Prelude.error "This won't ever hit bc this generates a compile-time error."
-- | Quasi-quoter for creating a Redis Lua script with placeholders for Redis keys and arguments.
--
-- > [script|SET ${Key "a-redis-key"} ${Literal 123}|]
--
-- **IMPORTANT**: It is NOT SAFE to return Redis keys using this. Our Redis APIs inject
-- "namespaces" (prefixes) on keys, and any keys returned by Lua will have their namespaces
-- applied. If you try to reuse those keys in follow-up queries, namespaces will be doubly-applied.
script :: QQ.QuasiQuoter
script =
QQ.QuasiQuoter
{ QQ.quoteExp = qqScript,
QQ.quoteType = Prelude.error "script not supported in types",
QQ.quotePat = Prelude.error "script not supported in patterns",
QQ.quoteDec = Prelude.error "script not supported in declarations"
}
qqScript :: Prelude.String -> TH.Q TH.Exp
qqScript scriptWithVars = do
let quotedScript = Text.fromList scriptWithVars
let parseResult = P.parse parser "" quotedScript
case parseResult of
Left err -> Prelude.error <| "Failed to parse script: " ++ P.errorBundlePretty err
Right tokens -> do
paramsExp <-
tokens
|> Control.Monad.mapM toEvaluatedToken
|> map TH.ListE
quotedScriptExp <- [|quotedScript|]
pure <| (TH.VarE 'scriptFromEvaluatedTokens) `TH.AppE` quotedScriptExp `TH.AppE` paramsExp
----------------------------
-- Script template compile-time evaluation
----------------------------
data EvaluatedToken
= EvaluatedText Text
| EvaluatedVariable EvaluatedParam
deriving (Show, Eq)
data EvaluatedParam = EvaluatedParam
{ kind :: ParamKind,
value :: Text
}
deriving (Eq, Show)
data ParamKind = RedisKey | ArbitraryValue
deriving (Eq, Show)
toEvaluatedToken :: Tokens -> TH.Q TH.Exp
toEvaluatedToken token =
case token of
ScriptText text -> [|EvaluatedText text|]
ScriptVariable var -> pure <| (TH.VarE 'evaluateScriptParam) `TH.AppE` (varToExp var)
evaluateScriptParam :: (HasScriptParam a) => a -> EvaluatedToken
evaluateScriptParam scriptParam =
case getScriptParam scriptParam of
Key a ->
EvaluatedVariable <|
EvaluatedParam
{ kind = RedisKey,
value = unquoteString (Debug.toString a)
}
Literal a ->
EvaluatedVariable <|
EvaluatedParam
{ kind = ArbitraryValue,
value = unquoteString (Debug.toString a)
}
-- | Remove leading and trailing quotes from a string
unquoteString :: Text -> Text
unquoteString str =
str
|> Data.Text.stripPrefix "\""
|> Maybe.andThen (Data.Text.stripSuffix "\"")
|> Maybe.withDefault str
varToExp :: Text -> TH.Exp
varToExp var =
case parseExp (Text.toList var) of
Left err -> Prelude.error <| "Failed to parse variable: " ++ err
Right exp -> exp
-----------------------------
-- Script record construction
-----------------------------
data ScriptBuilder = ScriptBuilder
{ buffer :: Text,
keyIdx :: Int,
keyList :: List Text,
argIdx :: Int,
argList :: List Text
}
scriptFromEvaluatedTokens :: Text -> [EvaluatedToken] -> Script a
scriptFromEvaluatedTokens quasiQuotedString' evaluatedTokens =
let keyTpl n = "KEYS[" ++ Text.fromInt n ++ "]"
argTpl n = "ARGV[" ++ Text.fromInt n ++ "]"
script' =
List.foldl
( \token scriptBuilder@(ScriptBuilder {buffer, keyIdx, keyList, argIdx, argList}) ->
case token of
EvaluatedText text -> scriptBuilder {buffer = buffer ++ text}
EvaluatedVariable var ->
case kind var of
RedisKey ->
scriptBuilder
{ buffer = buffer ++ keyTpl (keyIdx + 1),
keyIdx = keyIdx + 1,
keyList = value var : keyList
}
ArbitraryValue ->
scriptBuilder
{ buffer = buffer ++ argTpl (argIdx + 1),
argIdx = argIdx + 1,
argList = value var : argList
}
)
(ScriptBuilder "" 0 [] 0 [])
evaluatedTokens
in Script
{ luaScript = buffer script',
quasiQuotedString = quasiQuotedString',
keys = List.reverse (keyList script'),
arguments = Log.mkSecret (List.reverse (argList script'))
}
-----------------------------
-- Quasi-quoted text parser
-----------------------------
-- | Tokens after parsing quasi-quoted text
data Tokens
= ScriptText Text
| ScriptVariable Text
deriving (Show, Eq)
type Parser = P.Parsec Void Text
parser :: Parser (List Tokens)
parser = do
(P.some (parseText <|> parseVariable))
<* P.eof
parseText :: Parser Tokens
parseText = do
text <- P.takeWhile1P (Just "some plain text") (/= '$')
pure <| ScriptText text
parseVariable :: Parser Tokens
parseVariable = do
_ <- PC.string "${"
_ <- PC.space
name <- P.takeWhile1P (Just "anything but '$', '{' or '}' (no records, sorry)") (\t -> t `notElem` ['$', '{', '}'])
_ <- PC.char '}'
pure <| ScriptVariable <| Text.trim name
---------------------------------------------
-- Helper functions for internal library use
---------------------------------------------
-- | EVALSHA hash numkeys [key [key ...]] [arg [arg ...]]
evalShaString :: Script a -> Text
evalShaString script'@(Script {keys, arguments}) =
let keyCount = keys |> List.length |> Text.fromInt
keys' = keys |> Text.join " "
args' = arguments |> Log.unSecret |> List.map (\_ -> "***") |> Text.join " "
hash = luaScriptHash script'
in "EVALSHA " ++ hash ++ " " ++ keyCount ++ " " ++ keys' ++ " " ++ args'
-- | SCRIPT LOAD "return KEYS[1]"
scriptLoadString :: Script a -> Text
scriptLoadString Script {luaScript} =
"SCRIPT LOAD \"" ++ luaScript ++ "\""
-- | Map the keys in the script to the keys in the Redis API
mapKeys :: (Text -> Task err Text) -> Script a -> Task err (Script a)
mapKeys fn script' = do
keys script'
|> List.map fn
|> Task.sequence
|> Task.map (\keys' -> script' {keys = keys'})
luaScriptHash :: Script a -> Text
luaScriptHash Script {luaScript} =
luaScript
|> Data.Text.Encoding.encodeUtf8
|> Crypto.Hash.SHA1.hash
|> toHex
toHex :: Data.ByteString.ByteString -> Text
toHex bytes =
bytes
|> Data.ByteString.unpack
|> List.map (Text.Printf.printf "%02x")
|> List.concat
|> Text.fromList