packages feed

bolty-0.2.0.0: src/Database/Bolty/Message/Response.hs

{-# LANGUAGE ApplicativeDo #-}

-- | Internal module. Not part of the public API.
module Database.Bolty.Message.Response
  ( Response(..)
  , Failure(..)
  , SuccessHello(..)
  , SuccessRun(..), successFields, successTFirst
  , SuccessRunAutoCommitTransaction(..)
  , SuccessRunExplicitTransaction(..)
  , makeResponseRunAutoCommitTransaction
  , SuccessDiscard(..), SuccessDiscardLast(..)
  , SuccessPull(..), QueryMeta(..)
  , makeSuccessPull, mapToSuccessPull
  , SuccessCommit(..)
  , RoutingTable(..)
  , parseRoutingTable
  , extractBookmark
  ) where

import           Data.Kind                     (Type)
import           Data.Int                      (Int64)
import qualified Data.HashMap.Lazy             as H
import qualified Data.Text                     as T
import qualified Data.Vector                   as V

import qualified Data.PackStream               as PS
import           Data.PackStream.Ps            as PS
import           Data.PackStream.Integer       (fromPSInteger)
import           Database.Bolty.Notification   (Notification, parseNotifications)
import           Database.Bolty.Plan           (PlanNode, ProfileNode, parsePlan, parseProfile)
import           Database.Bolty.Stats          (QueryStats, parseStats)
import           Database.Bolty.Record         (Record)

-- | A BOLT server response message.
--
-- the server always responds with one summary message if the connection is still open
-- the server always responds with zero or more detail messages before sending a summary message
type Response :: Type
data Response
  = RSuccess !(H.HashMap T.Text Ps)
  -- ^ https://neo4j.com/docs/bolt/current/bolt/message/#messages-success
  | RIgnored
  -- ^ https://neo4j.com/docs/bolt/current/bolt/message/#messages-ignored
  | RFailure Failure
  -- ^ https://neo4j.com/docs/bolt/current/bolt/message/#messages-failure
  | RRecord !Record
  -- ^ https://neo4j.com/docs/bolt/current/bolt/message/#messages-record
instance PS.PackStream Response where
  toPs (RSuccess success) = structureSingleton 0x70 $ toPs success
  toPs (RIgnored) = PsStructure 0x7E V.empty
  toPs (RFailure failure) = structureSingleton 0x7F $ toPs failure
  toPs (RRecord records) = structureSingleton 0x71 $ toPs records
  fromPs (PsStructure 0x70 fields) = PS.fromOneField fields RSuccess "expected exactly one field for Success"
  fromPs (PsStructure 0x7E fields) = if V.null fields then PS.Success RIgnored else PS.Error "additional data on Ignored request is not allowed"
  fromPs (PsStructure 0x7F fields) = PS.fromOneField fields RFailure "expected exactly one field for Failure"
  fromPs (PsStructure 0x71 fields) = PS.fromOneField fields RRecord "expected exactly one field for Record"
  fromPs _                         = PS.Error "Could not decode Response"



-- | Parse a RUN SUCCESS response for an auto-commit transaction.
makeResponseRunAutoCommitTransaction :: H.HashMap T.Text Ps -> Either T.Text SuccessRun
makeResponseRunAutoCommitTransaction map = do
  fields <- PS.lookupMaybeError "fields" map
  t_first <- PS.lookupMaybeError "t_first" map
  pure $ AutoCommitTransaction $ SuccessRunAutoCommitTransaction fields t_first



-- | Build a 'SuccessPull' from collected records and PULL SUCCESS metadata (Either version).
makeSuccessPull :: V.Vector Record -> H.HashMap T.Text Ps -> Either T.Text SuccessPull
makeSuccessPull records map = do
  let bookmark = case PS.lookupMaybeError "bookmark" map of
                   Right b -> Just b
                   Left _  -> Nothing
  t_last <- PS.lookupMaybeError "t_last" map
  type_ <- PS.lookupMaybeError "type" map
  db <- PS.lookupMaybeError "db" map
  let stats = H.lookup "stats" map
  let parsedStats = parseStats stats
  let plan = H.lookup "plan" map
  let profile = H.lookup "profile" map
  let notifications = H.lookup "notifications" map
  let parsedNotifications = parseNotifications notifications
  let parsedPlan = parsePlan plan
  let parsedProfile = parseProfile profile
  let infos = QueryMeta{bookmark, t_last, type_, stats, parsedStats, plan, profile, notifications, parsedNotifications, parsedPlan, parsedProfile, db}
  pure $ SuccessPull{records, infos}





-- | A server-reported failure with a Neo4j error code and human-readable message.
type Failure :: Type
data Failure = Failure
  { code :: !T.Text
  , message :: !T.Text
  }
instance PS.PackStream Failure where
  toPs (Failure code message) = PsDictionary $ H.fromList [("code", toPs code), ("message", toPs message)]
  fromPs (PsDictionary map) = do
    code <- PS.lookupWithError "code" map "expected \"code\" in Failure response"
    message <- PS.lookupWithError "message" map "expected \"message\" in Failure response"
    PS.Success $ Failure code message
  fromPs _ = PS.Error "expected dictionary for Failure"




-- | Parsed HELLO SUCCESS metadata.
type SuccessHello :: Type
data SuccessHello = SuccessHello
  { server :: !T.Text
  -- ^ server agent string, example "Neo4j/4.1.0"
  , connection_id :: !T.Text
  -- ^ unique identifier of the bolt connection used on the server side, example: "bolt-61"
  , hints :: !(H.HashMap T.Text Ps)
  -- ^ set of optional configuration hints to be considered by the driver
  }

-- | Parsed RUN SUCCESS metadata, either auto-commit or explicit transaction.
type SuccessRun :: Type
data SuccessRun
  = AutoCommitTransaction SuccessRunAutoCommitTransaction
  | ExplicitTransaction SuccessRunExplicitTransaction

-- | Extract the result field names from a 'SuccessRun'.
successFields :: SuccessRun -> V.Vector T.Text
successFields (AutoCommitTransaction (SuccessRunAutoCommitTransaction fields _)) = fields
successFields (ExplicitTransaction (SuccessRunExplicitTransaction fields _ _)) = fields

-- | Extract the @t_first@ timing value from a 'SuccessRun'.
successTFirst :: SuccessRun -> Int64
successTFirst (AutoCommitTransaction (SuccessRunAutoCommitTransaction _ tf)) = tf
successTFirst (ExplicitTransaction (SuccessRunExplicitTransaction _ tf _)) = tf

-- | RUN SUCCESS metadata for an auto-commit transaction.
type SuccessRunAutoCommitTransaction :: Type
data SuccessRunAutoCommitTransaction = SuccessRunAutoCommitTransaction
  { fields :: !(V.Vector T.Text)
  -- ^ the fields of the return result. e.g. [“name”, “age”, …]
  , t_first :: !Int64
  -- ^ the time, specified in ms, which the first record in the result stream is available after
  }

-- | RUN SUCCESS metadata for an explicit transaction.
type SuccessRunExplicitTransaction :: Type
data SuccessRunExplicitTransaction = SuccessRunExplicitTransaction
  { fields :: !(V.Vector T.Text)
  -- ^ the fields of the return result. e.g. [“name”, “age”, …]
  , t_first :: !Int64
  -- ^ the time, specified in ms, which the first record in the result stream is available after
  , qid :: !Int64
  -- ^ specifies the server assigned statement ID to reference the server side result-set with commencing BEGIN+RUN+PULL and BEGIN+RUN+DISCARD messages.
  }

-- | Parsed DISCARD SUCCESS response.
type SuccessDiscard :: Type
data SuccessDiscard
  = DiscardMore
  | DiscardLast SuccessDiscardLast

-- | Final DISCARD SUCCESS metadata with bookmark and database.
type SuccessDiscardLast :: Type
data SuccessDiscardLast = SuccessDiscardLast
  { bookmark :: !T.Text
  -- ^ the bookmark after committing this transaction (Auto-commit Transaction only)
  , db :: !T.Text
  -- ^ the database name where the query is executed.
  }

-- | Build a 'SuccessPull' from collected records and PULL SUCCESS metadata (Result version).
mapToSuccessPull :: V.Vector Record -> H.HashMap T.Text Ps -> PS.Result SuccessPull
mapToSuccessPull records map = do
  bookmark :: Maybe T.Text <- PS.lookupMaybe "bookmark" map
  t_last :: Int64    <- PS.lookupWithError "t_last" map "could not find \"t_last\" in Pull response"
  type_ :: T.Text    <- PS.lookupWithError "type" map "could not find \"type\" in Pull response"
  db :: T.Text       <- PS.lookupWithError "db" map "could not find \"db\" in Pull response"
  let stats         = H.lookup "stats" map
  let parsedStats   = parseStats stats
  let plan          = H.lookup "plan" map
  let profile       = H.lookup "profile" map
  let notifications = H.lookup "notifications" map
  let parsedNotifications = parseNotifications notifications
  let parsedPlan    = parsePlan plan
  let parsedProfile = parseProfile profile
  let infos = QueryMeta{bookmark, t_last, type_, stats, parsedStats, plan, profile, notifications, parsedNotifications, parsedPlan, parsedProfile, db}
  pure $ SuccessPull{records, infos}




-- | Full query result: records plus server metadata.
type SuccessPull :: Type
data SuccessPull = SuccessPull
  { records :: !(V.Vector Record)
  , infos   :: !QueryMeta
  }

-- | Server metadata from a PULL SUCCESS response.
type QueryMeta :: Type
data QueryMeta = QueryMeta
  { bookmark :: !(Maybe T.Text)
  -- ^ the bookmark after committing this transaction (Autocommit Transaction only, absent in explicit transactions).
  , t_last :: !Int64
  -- ^ the time, specified in ms, which the last record in the result stream is consumed after.
  , type_ :: !T.Text
  -- ^ the type of the statement, e.g. "r" for read-only statement, "w" for write-only statement, "rw" for read-and-write, and "s" for schema only.
  , stats :: !(Maybe Ps)
  -- ^ counter information, such as db-hits etc. May be omitted.
  , parsedStats :: !(Maybe QueryStats)
  -- ^ parsed stats from the raw field above.
  , plan :: !(Maybe Ps)
  -- ^ plan result. May be omitted.
  , profile :: !(Maybe Ps)
  -- ^ profile result. May be omitted.
  , notifications :: !(Maybe Ps)
  -- ^ a list of all notifications generated during execution of this statement. May be omitted.
  , parsedNotifications :: !(V.Vector Notification)
  -- ^ parsed notifications from the raw field above.
  , parsedPlan :: !(Maybe PlanNode)
  -- ^ parsed plan tree from the raw @plan@ field above.
  , parsedProfile :: !(Maybe ProfileNode)
  -- ^ parsed profile tree from the raw @profile@ field above.
  , db :: !T.Text
  -- ^ the database name where the query was executed (v4.0+).
  }
  deriving stock (Show)

-- | Parsed COMMIT SUCCESS metadata.
type SuccessCommit :: Type
data SuccessCommit = SuccessCommit
  { bookmark :: !T.Text
  -- ^ the bookmark after committing this transaction.
  }


-- | A parsed routing table returned by the ROUTE message.
type RoutingTable :: Type
data RoutingTable = RoutingTable
  { ttl     :: !Int64
  -- ^ Seconds to cache this routing table.
  , db      :: !T.Text
  -- ^ Database name this routing table is for.
  , routers :: !(V.Vector T.Text)
  -- ^ "host:port" addresses for ROUTE role.
  , readers :: !(V.Vector T.Text)
  -- ^ "host:port" addresses for READ role.
  , writers :: !(V.Vector T.Text)
  -- ^ "host:port" addresses for WRITE role.
  }
  deriving stock (Show, Eq)


-- | Parse a routing table from the SUCCESS response metadata of a ROUTE message.
-- The expected wire format is:
-- @{"rt": {"ttl": N, "db": "name", "servers": [{"addresses": [...], "role": "WRITE"}, ...]}}@
parseRoutingTable :: H.HashMap T.Text Ps -> Either T.Text RoutingTable
parseRoutingTable meta = do
  rtPs <- case H.lookup "rt" meta of
    Just (PsDictionary m) -> Right m
    Just _  -> Left "\"rt\" field should be a dictionary"
    Nothing -> Left "\"rt\" key not found in ROUTE response"
  ttl <- case H.lookup "ttl" rtPs of
    Just (PsInteger n) -> case fromPSInteger n of
      Just i  -> Right i
      Nothing -> Left "\"ttl\" integer out of Int64 range"
    Just _  -> Left "\"ttl\" should be an integer"
    Nothing -> Left "\"ttl\" not found in routing table"
  db <- case H.lookup "db" rtPs of
    Just (PsString s) -> Right s
    Just _  -> Left "\"db\" should be a string"
    Nothing -> Left "\"db\" not found in routing table"
  serversPs <- case H.lookup "servers" rtPs of
    Just (PsList v) -> Right v
    Just _  -> Left "\"servers\" should be a list"
    Nothing -> Left "\"servers\" not found in routing table"
  let (routers, readers, writers) = V.foldl' classifyServer (V.empty, V.empty, V.empty) serversPs
  Right RoutingTable{ttl, db, routers, readers, writers}


-- | Classify a single server entry from the routing table by its role.
classifyServer :: (V.Vector T.Text, V.Vector T.Text, V.Vector T.Text)
               -> Ps
               -> (V.Vector T.Text, V.Vector T.Text, V.Vector T.Text)
classifyServer (rout, rd, wr) (PsDictionary m) =
  case (H.lookup "role" m, H.lookup "addresses" m) of
    (Just (PsString role), Just (PsList addrs)) ->
      let addrTexts = V.mapMaybe extractText addrs
      in case role of
           "ROUTE" -> (rout <> addrTexts, rd, wr)
           "READ"  -> (rout, rd <> addrTexts, wr)
           "WRITE" -> (rout, rd, wr <> addrTexts)
           _       -> (rout, rd, wr)
    _ -> (rout, rd, wr)
classifyServer acc _ = acc


extractText :: Ps -> Maybe T.Text
extractText (PsString t) = Just t
extractText _            = Nothing


-- | Extract the bookmark from a SUCCESS response metadata (e.g. from COMMIT).
-- Returns 'Nothing' if the @bookmark@ key is absent or not a text value.
extractBookmark :: H.HashMap T.Text Ps -> Maybe T.Text
extractBookmark meta = case H.lookup "bookmark" meta of
  Just (PsString t) -> Just t
  _                 -> Nothing