hask-redis-mux-0.1.0.0: lib/redis-command-client/Database/Redis/Command.hs
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE OverloadedStrings #-}
-- | High-level Redis command interface built on top of 'Client'.
--
-- Provides the 'RedisCommands' typeclass with methods for standard Redis commands
-- (strings, hashes, lists, sets, sorted sets, geo), a 'RedisCommandClient' monad
-- that manages connection state and incremental RESP parsing, and typed error handling
-- via 'RedisError'.
--
-- @since 0.1.0.0
module Database.Redis.Command
( -- * Core types
ClientState (..)
, RedisCommandClient (..)
, RedisCommands (..)
, ClientReplyValues (..)
-- * Errors
, RedisError (..)
-- * Geo types
, GeoUnit (..)
, GeoRadiusFlag (..)
, GeoSearchFrom (..)
, GeoSearchBy (..)
, GeoSearchOption (..)
-- * Helpers
, wrapInRay
, encodeCommand
, encodeCommandBuilder
, encodeSetBuilder
, encodeGetBuilder
, encodeBulkArg
, showBS
, geoUnitKeyword
, geoRadiusFlagToList
, geoSearchFromToList
, geoSearchByToList
, geoSearchOptionToList
-- * Parsing
, parseWith
, parseManyWith
-- * FromResp conversion
, convertResp
) where
import Control.Exception (throwIO)
import Control.Monad.IO.Class (MonadIO (..))
import Control.Monad.State as State (MonadState (get, put),
StateT)
import qualified Data.Attoparsec.ByteString.Char8 as StrictParse
import Data.ByteString (ByteString)
import qualified Data.ByteString.Builder as Builder
import qualified Data.ByteString.Char8 as BS8
import qualified Data.ByteString.Lazy as LBS
import Data.Kind (Type)
import Database.Redis.Client (Client (..),
ConnectionStatus (..))
import Database.Redis.FromResp (FromResp (..))
import Database.Redis.RedisError (RedisError (..))
import Database.Redis.Resp (Encodable (encode),
RespData (..), parseRespData)
-- | Mutable state carried through a 'RedisCommandClient' session: the live connection
-- and an unparsed RESP byte buffer from previous receives.
data ClientState client = ClientState
{ getClient :: client 'Connected,
getParseBuffer :: BS8.ByteString
}
-- | A monad for sequencing Redis commands over a single connection.
-- Wraps 'StateT' over 'ClientState' to manage the connection handle and
-- an incremental parse buffer, so callers never deal with raw bytes.
data RedisCommandClient client (a :: Type) where
RedisCommandClient :: (Client client) => {runRedisCommandClient :: State.StateT (ClientState client) IO a} -> RedisCommandClient client a
instance (Client client) => Functor (RedisCommandClient client) where
fmap :: (a -> b) -> RedisCommandClient client a -> RedisCommandClient client b
fmap f (RedisCommandClient s) = RedisCommandClient (fmap f s)
instance (Client client) => Applicative (RedisCommandClient client) where
pure :: a -> RedisCommandClient client a
pure = RedisCommandClient . pure
(<*>) :: RedisCommandClient client (a -> b) -> RedisCommandClient client a -> RedisCommandClient client b
RedisCommandClient f <*> RedisCommandClient s = RedisCommandClient (f <*> s)
instance (Client client) => Monad (RedisCommandClient client) where
(>>=) :: RedisCommandClient client a -> (a -> RedisCommandClient client b) -> RedisCommandClient client b
RedisCommandClient s >>= f = RedisCommandClient (s >>= \a -> let RedisCommandClient s' = f a in s')
instance (Client client) => MonadIO (RedisCommandClient client) where
liftIO :: IO a -> RedisCommandClient client a
liftIO = RedisCommandClient . liftIO
instance (Client client) => MonadState (ClientState client) (RedisCommandClient client) where
get :: RedisCommandClient client (ClientState client)
get = RedisCommandClient State.get
put :: ClientState client -> RedisCommandClient client ()
put = RedisCommandClient . State.put
instance (Client client) => MonadFail (RedisCommandClient client) where
fail :: String -> RedisCommandClient client a
fail = RedisCommandClient . liftIO . fail
-- | The standard set of Redis commands. Implemented for both single-node
-- ('RedisCommandClient') and cluster ('ClusterCommandClient') monads.
-- Keys and values use strict 'ByteString' to avoid O(n) String conversions.
-- Command return types are polymorphic via 'FromResp', allowing typed results.
class (MonadIO m) => RedisCommands m where
auth :: (FromResp a) => ByteString -> ByteString -> m a
ping :: (FromResp a) => m a
set :: (FromResp a) => ByteString -> ByteString -> m a
get :: (FromResp a) => ByteString -> m a
mget :: (FromResp a) => [ByteString] -> m a
setnx :: (FromResp a) => ByteString -> ByteString -> m a
decr :: (FromResp a) => ByteString -> m a
psetex :: (FromResp a) => ByteString -> Int -> ByteString -> m a
bulkSet :: (FromResp a) => [(ByteString, ByteString)] -> m a
flushAll :: (FromResp a) => m a
dbsize :: (FromResp a) => m a
del :: (FromResp a) => [ByteString] -> m a
exists :: (FromResp a) => [ByteString] -> m a
incr :: (FromResp a) => ByteString -> m a
hset :: (FromResp a) => ByteString -> ByteString -> ByteString -> m a
hget :: (FromResp a) => ByteString -> ByteString -> m a
hmget :: (FromResp a) => ByteString -> [ByteString] -> m a
hexists :: (FromResp a) => ByteString -> ByteString -> m a
lpush :: (FromResp a) => ByteString -> [ByteString] -> m a
lrange :: (FromResp a) => ByteString -> Int -> Int -> m a
expire :: (FromResp a) => ByteString -> Int -> m a
ttl :: (FromResp a) => ByteString -> m a
rpush :: (FromResp a) => ByteString -> [ByteString] -> m a
lpop :: (FromResp a) => ByteString -> m a
rpop :: (FromResp a) => ByteString -> m a
sadd :: (FromResp a) => ByteString -> [ByteString] -> m a
smembers :: (FromResp a) => ByteString -> m a
scard :: (FromResp a) => ByteString -> m a
sismember :: (FromResp a) => ByteString -> ByteString -> m a
hdel :: (FromResp a) => ByteString -> [ByteString] -> m a
hkeys :: (FromResp a) => ByteString -> m a
hvals :: (FromResp a) => ByteString -> m a
llen :: (FromResp a) => ByteString -> m a
lindex :: (FromResp a) => ByteString -> Int -> m a
clientSetInfo :: (FromResp a) => [ByteString] -> m a
clientReply :: ClientReplyValues -> m (Maybe RespData)
zadd :: (FromResp a) => ByteString -> [(Int, ByteString)] -> m a
zrange :: (FromResp a) => ByteString -> Int -> Int -> Bool -> m a
geoadd :: (FromResp a) => ByteString -> [(Double, Double, ByteString)] -> m a
geodist :: (FromResp a) => ByteString -> ByteString -> ByteString -> Maybe GeoUnit -> m a
geohash :: (FromResp a) => ByteString -> [ByteString] -> m a
geopos :: (FromResp a) => ByteString -> [ByteString] -> m a
georadius :: (FromResp a) => ByteString -> Double -> Double -> Double -> GeoUnit -> [GeoRadiusFlag] -> m a
georadiusRo :: (FromResp a) => ByteString -> Double -> Double -> Double -> GeoUnit -> [GeoRadiusFlag] -> m a
georadiusByMember :: (FromResp a) => ByteString -> ByteString -> Double -> GeoUnit -> [GeoRadiusFlag] -> m a
georadiusByMemberRo :: (FromResp a) => ByteString -> ByteString -> Double -> GeoUnit -> [GeoRadiusFlag] -> m a
geosearch :: (FromResp a) => ByteString -> GeoSearchFrom -> GeoSearchBy -> [GeoSearchOption] -> m a
geosearchstore :: (FromResp a) => ByteString -> ByteString -> GeoSearchFrom -> GeoSearchBy -> [GeoSearchOption] -> Bool -> m a
clusterSlots :: (FromResp a) => m a
-- | Helper to convert a showable value to ByteString for use in commands.
showBS :: (Show a) => a -> ByteString
showBS = BS8.pack . show
-- | Wrap a list of strict ByteStrings into a RESP array of bulk strings.
wrapInRay :: [ByteString] -> RespData
wrapInRay inp =
let !res = RespArray . map RespBulkString $ inp
in res
-- | Encode a Redis command (list of arguments) into a Builder for efficient batching.
-- Used by the multiplexer to defer materialization until the writer batches commands.
encodeCommandBuilder :: [ByteString] -> Builder.Builder
encodeCommandBuilder args =
Builder.char8 '*' <> Builder.intDec (length args) <> Builder.byteString "\r\n" <>
foldMap encodeBulkArg args
-- | Encode a single bulk string argument: $LEN\r\nDATA\r\n
{-# INLINE encodeBulkArg #-}
encodeBulkArg :: ByteString -> Builder.Builder
encodeBulkArg a = Builder.char8 '$' <> Builder.intDec (BS8.length a) <> Builder.byteString "\r\n"
<> Builder.byteString a <> Builder.byteString "\r\n"
-- | Pre-computed RESP preamble for SET: *3\r\n$3\r\nSET\r\n
setPreamble :: Builder.Builder
setPreamble = Builder.byteString "*3\r\n$3\r\nSET\r\n"
{-# NOINLINE setPreamble #-}
-- | Pre-computed RESP preamble for GET: *2\r\n$3\r\nGET\r\n
getPreamble :: Builder.Builder
getPreamble = Builder.byteString "*2\r\n$3\r\nGET\r\n"
{-# NOINLINE getPreamble #-}
-- | Specialized SET encoder: avoids list construction, length, and foldMap.
{-# INLINE encodeSetBuilder #-}
encodeSetBuilder :: ByteString -> ByteString -> Builder.Builder
encodeSetBuilder key val = setPreamble <> encodeBulkArg key <> encodeBulkArg val
-- | Specialized GET encoder: avoids list construction, length, and foldMap.
{-# INLINE encodeGetBuilder #-}
encodeGetBuilder :: ByteString -> Builder.Builder
encodeGetBuilder key = getPreamble <> encodeBulkArg key
-- | Encode a Redis command (list of arguments) into its RESP wire format as a strict ByteString.
-- Used by the multiplexer to pre-encode commands before queuing.
encodeCommand :: [ByteString] -> ByteString
encodeCommand args = LBS.toStrict $ Builder.toLazyByteString $ encodeCommandBuilder args
-- | Send a command and parse the response.
executeCommand :: (Client client) => [ByteString] -> RedisCommandClient client RespData
executeCommand args = do
ClientState !client _ <- State.get
liftIO $ send client (Builder.toLazyByteString . encode $ wrapInRay args)
parseWith (receive client)
-- | Convert a raw 'RespData' value using 'FromResp', throwing on failure.
convertResp :: (FromResp a, MonadIO m) => RespData -> m a
convertResp rd = case fromResp rd of
Right a -> return a
Left err -> liftIO $ throwIO err
-- | Execute a command and convert the result via 'FromResp'.
executeCommandAs :: (Client client, FromResp a) => [ByteString] -> RedisCommandClient client a
executeCommandAs args = executeCommand args >>= convertResp
-- | Distance unit for Redis GEO commands.
data GeoUnit
= Meters
| Kilometers
| Miles
| Feet
deriving (Eq, Show)
-- | Convert a 'GeoUnit' to its Redis protocol keyword.
geoUnitKeyword :: GeoUnit -> ByteString
geoUnitKeyword unit =
case unit of
Meters -> "M"
Kilometers -> "KM"
Miles -> "MI"
Feet -> "FT"
-- | Optional flags for GEORADIUS and GEORADIUSBYMEMBER commands.
data GeoRadiusFlag
= GeoWithCoord
| GeoWithDist
| GeoWithHash
| GeoRadiusCount Int Bool -- Bool indicates whether ANY is appended
| GeoRadiusAsc
| GeoRadiusDesc
| GeoRadiusStore ByteString
| GeoRadiusStoreDist ByteString
deriving (Eq, Show)
-- | Convert a 'GeoRadiusFlag' to its Redis protocol argument list.
geoRadiusFlagToList :: GeoRadiusFlag -> [ByteString]
geoRadiusFlagToList flag =
case flag of
GeoWithCoord -> ["WITHCOORD"]
GeoWithDist -> ["WITHDIST"]
GeoWithHash -> ["WITHHASH"]
GeoRadiusCount n useAny -> ["COUNT", showBS n] <> ["ANY" | useAny]
GeoRadiusAsc -> ["ASC"]
GeoRadiusDesc -> ["DESC"]
GeoRadiusStore key -> ["STORE", key]
GeoRadiusStoreDist key -> ["STOREDIST", key]
-- | Origin for a GEOSEARCH query: either a longitude\/latitude pair or an existing member.
data GeoSearchFrom
= GeoFromLonLat Double Double
| GeoFromMember ByteString
deriving (Eq, Show)
-- | Convert a 'GeoSearchFrom' to its Redis protocol argument list.
geoSearchFromToList :: GeoSearchFrom -> [ByteString]
geoSearchFromToList fromSpec =
case fromSpec of
GeoFromLonLat lon lat -> ["FROMLONLAT", showBS lon, showBS lat]
GeoFromMember member -> ["FROMMEMBER", member]
-- | Shape for a GEOSEARCH query: circular radius or rectangular box.
data GeoSearchBy
= GeoByRadius Double GeoUnit
| GeoByBox Double Double GeoUnit
deriving (Eq, Show)
-- | Convert a 'GeoSearchBy' to its Redis protocol argument list.
geoSearchByToList :: GeoSearchBy -> [ByteString]
geoSearchByToList bySpec =
case bySpec of
GeoByRadius radius unit -> ["BYRADIUS", showBS radius, geoUnitKeyword unit]
GeoByBox width height unit -> ["BYBOX", showBS width, showBS height, geoUnitKeyword unit]
-- | Optional modifiers for GEOSEARCH: include coordinates, distances, hashes,
-- limit count, or sort order.
data GeoSearchOption
= GeoSearchWithCoord
| GeoSearchWithDist
| GeoSearchWithHash
| GeoSearchCount Int Bool -- Bool indicates ANY
| GeoSearchAsc
| GeoSearchDesc
deriving (Eq, Show)
-- | Convert a 'GeoSearchOption' to its Redis protocol argument list.
geoSearchOptionToList :: GeoSearchOption -> [ByteString]
geoSearchOptionToList opt =
case opt of
GeoSearchWithCoord -> ["WITHCOORD"]
GeoSearchWithDist -> ["WITHDIST"]
GeoSearchWithHash -> ["WITHHASH"]
GeoSearchCount n useAny -> ["COUNT", showBS n] <> ["ANY" | useAny]
GeoSearchAsc -> ["ASC"]
GeoSearchDesc -> ["DESC"]
-- | Values for the CLIENT REPLY command.
data ClientReplyValues = OFF | ON | SKIP
deriving (Eq, Show)
instance (Client client) => RedisCommands (RedisCommandClient client) where
ping = executeCommandAs ["PING"]
set k v = executeCommandAs ["SET", k, v]
get k = executeCommandAs ["GET", k]
mget keys = executeCommandAs ("MGET" : keys)
setnx key value = executeCommandAs ["SETNX", key, value]
decr key = executeCommandAs ["DECR", key]
psetex key milliseconds value = executeCommandAs ["PSETEX", key, showBS milliseconds, value]
auth username password = executeCommandAs ["HELLO", "3", "AUTH", username, password]
bulkSet kvs = executeCommandAs (["MSET"] <> concatMap (\(k, v) -> [k, v]) kvs)
flushAll = executeCommandAs ["FLUSHALL"]
dbsize = executeCommandAs ["DBSIZE"]
del keys = executeCommandAs ("DEL" : keys)
exists keys = executeCommandAs ("EXISTS" : keys)
incr key = executeCommandAs ["INCR", key]
hset key field value = executeCommandAs ["HSET", key, field, value]
hget key field = executeCommandAs ["HGET", key, field]
hmget key fields = executeCommandAs ("HMGET" : key : fields)
hexists key field = executeCommandAs ["HEXISTS", key, field]
lpush key values = executeCommandAs ("LPUSH" : key : values)
lrange key start stop = executeCommandAs ["LRANGE", key, showBS start, showBS stop]
expire key seconds = executeCommandAs ["EXPIRE", key, showBS seconds]
ttl key = executeCommandAs ["TTL", key]
rpush key values = executeCommandAs ("RPUSH" : key : values)
lpop key = executeCommandAs ["LPOP", key]
rpop key = executeCommandAs ["RPOP", key]
sadd key members = executeCommandAs ("SADD" : key : members)
smembers key = executeCommandAs ["SMEMBERS", key]
scard key = executeCommandAs ["SCARD", key]
sismember key member = executeCommandAs ["SISMEMBER", key, member]
hdel key fields = executeCommandAs ("HDEL" : key : fields)
hkeys key = executeCommandAs ["HKEYS", key]
hvals key = executeCommandAs ["HVALS", key]
llen key = executeCommandAs ["LLEN", key]
lindex key index = executeCommandAs ["LINDEX", key, showBS index]
clientSetInfo info = executeCommandAs (["CLIENT", "SETINFO"] ++ info)
clusterSlots = executeCommandAs ["CLUSTER", "SLOTS"]
clientReply val = do
ClientState !client _ <- State.get
liftIO $ send client (Builder.toLazyByteString . encode $ wrapInRay ["CLIENT", "REPLY", showBS val])
case val of
ON -> Just <$> parseWith (receive client)
_ -> return Nothing
zadd key members =
let payload = concatMap (\(score, member) -> [showBS score, member]) members
in executeCommandAs ("ZADD" : key : payload)
zrange key start stop withScores =
let base = ["ZRANGE", key, showBS start, showBS stop]
command = if withScores then base ++ ["WITHSCORES"] else base
in executeCommandAs command
geoadd key entries =
let payload = concatMap (\(lon, lat, member) -> [showBS lon, showBS lat, member]) entries
in executeCommandAs ("GEOADD" : key : payload)
geodist key member1 member2 unit =
let unitPart = maybe [] (\u -> [geoUnitKeyword u]) unit
in executeCommandAs (["GEODIST", key, member1, member2] ++ unitPart)
geohash key members = executeCommandAs ("GEOHASH" : key : members)
geopos key members = executeCommandAs ("GEOPOS" : key : members)
georadius key longitude latitude radius unit flags =
let base = ["GEORADIUS", key, showBS longitude, showBS latitude, showBS radius, geoUnitKeyword unit]
in executeCommandAs (base ++ concatMap geoRadiusFlagToList flags)
georadiusRo key longitude latitude radius unit flags =
let base = ["GEORADIUS_RO", key, showBS longitude, showBS latitude, showBS radius, geoUnitKeyword unit]
in executeCommandAs (base ++ concatMap geoRadiusFlagToList flags)
georadiusByMember key member radius unit flags =
let base = ["GEORADIUSBYMEMBER", key, member, showBS radius, geoUnitKeyword unit]
in executeCommandAs (base ++ concatMap geoRadiusFlagToList flags)
georadiusByMemberRo key member radius unit flags =
let base = ["GEORADIUSBYMEMBER_RO", key, member, showBS radius, geoUnitKeyword unit]
in executeCommandAs (base ++ concatMap geoRadiusFlagToList flags)
geosearch key fromSpec bySpec options =
executeCommandAs (["GEOSEARCH", key]
++ geoSearchFromToList fromSpec
++ geoSearchByToList bySpec
++ concatMap geoSearchOptionToList options)
geosearchstore dest source fromSpec bySpec options storeDist =
let base = ["GEOSEARCHSTORE", dest, source]
++ geoSearchFromToList fromSpec
++ geoSearchByToList bySpec
++ concatMap geoSearchOptionToList options
command = if storeDist then base ++ ["STOREDIST"] else base
in executeCommandAs command
-- | Receive exactly one RESP value, fetching more bytes from the connection as needed.
-- Throws 'ParseError' on malformed data and 'ConnectionClosed' if the remote end hangs up.
parseWith :: (Client client, MonadIO m, MonadState (ClientState client) m) => m BS8.ByteString -> m RespData
parseWith recv = do
result <- parseManyWith 1 recv
case result of
[x] -> return x
_ -> liftIO $ throwIO $ ParseError "parseWith: expected exactly one result"
-- | Receive exactly @cnt@ RESP values from the connection, performing incremental
-- parsing against the internal buffer and fetching more bytes as needed.
parseManyWith :: (Client client, MonadIO m, MonadState (ClientState client) m) => Int -> m BS8.ByteString -> m [RespData]
parseManyWith cnt recv = do
(ClientState !client !input) <- State.get
case StrictParse.parse (StrictParse.count cnt parseRespData) input of
StrictParse.Fail _ _ err -> liftIO $ throwIO $ ParseError err
part@(StrictParse.Partial _) -> runUntilDone client part recv
StrictParse.Done remainder !r -> do
State.put (ClientState client remainder)
return r
where
runUntilDone :: (Client client, MonadIO m, MonadState (ClientState client) m) => client 'Connected -> StrictParse.IResult BS8.ByteString r -> m BS8.ByteString -> m r
runUntilDone _client (StrictParse.Fail _ _ err) _ = liftIO $ throwIO $ ParseError err
runUntilDone client (StrictParse.Partial f) getMore = do
moreData <- getMore
if BS8.null moreData
then liftIO $ throwIO ConnectionClosed
else runUntilDone client (f moreData) getMore
runUntilDone client (StrictParse.Done remainder !r) _ = do
State.put (ClientState client remainder)
return r