packages feed

bolty-streamly-0.1.0.2: src/Database/Bolty/Streamly.hs

-- | Streamly streaming interface for bolty Neo4j queries.
--
-- Instead of buffering all result records into a 'V.Vector', this module
-- yields records one-by-one as a @'Stream' IO 'Record'@,
-- allowing constant-memory consumption of large result sets.
--
-- Streams returned by pool, routing, and session functions manage connection
-- lifetime automatically via @bracketIO@ — the connection is acquired when
-- the stream starts and released when it finishes, errors, or is garbage
-- collected. Streams are ordinary values that can be stored, composed,
-- and passed to other functions.
--
-- @
-- import qualified Database.Bolty           as Bolt
-- import qualified Database.Bolty.Streamly  as BoltS
-- import qualified Streamly.Data.Stream     as Stream
-- import qualified Streamly.Data.Fold       as Fold
--
-- main :: IO ()
-- main = do
--   pool <- Bolt.createPool cfg Bolt.defaultPoolConfig
--   let s = BoltS.poolStream pool \"MATCH (n) RETURN n\"
--   count <- Stream.fold Fold.length s
--   Bolt.destroyPool pool
-- @
module Database.Bolty.Streamly
  ( -- * Streaming queries (bare connection)
    queryStream
  , queryStreamP
  , queryStreamAs
  , queryStreamPAs
    -- * Low-level streaming
  , pullStream
    -- * Pool-based streaming
  , poolStream
  , poolStreamP
  , poolStreamAs
  , poolStreamPAs
    -- * Routing pool streaming
  , routingStream
  , routingStreamP
  , routingStreamAs
  , routingStreamPAs
    -- * Session streaming
  , sessionReadStream
  , sessionReadStreamP
  , sessionWriteStream
  , sessionWriteStreamP
  , sessionReadStreamAs
  , sessionReadStreamPAs
  , sessionWriteStreamAs
  , sessionWriteStreamPAs
    -- * Re-exports
  , Stream
  ) where

import           Control.Exception              (throwIO, SomeException, try)
import           Data.Kind                      (Type)
import           Data.Text                      (Text)
import           GHC.Stack                      (HasCallStack)
import qualified Data.HashMap.Lazy              as H
import qualified Data.PackStream.Ps             as PS
import           Data.PackStream.Result         (Result(..))
import qualified Data.Vector                    as V
import           Streamly.Data.Stream           (Stream)
import qualified Streamly.Data.Stream           as Stream

import           Database.Bolty.AccessMode      (AccessMode(..))
import           Database.Bolty.Connection      (requestResponseRunIO)
import qualified Database.Bolty.Connection.Pipe as P
import           Database.Bolty.Connection.Type
import           Database.Bolty.Decode          (RowDecoder, decodeRow)
import           Database.Bolty.Message.Request (Request(..), Pull(..), Begin(Begin), TelemetryApi(..))
import           Database.Bolty.Message.Response (Response(..), Failure(..), successFields)
import           Database.Bolty.Pool            (BoltPool, CheckedOutConnection(..),
                                                  acquireConnection, releaseConnection,
                                                  releaseConnectionOnError)
import           Database.Bolty.Record          (Record)
import           Database.Bolty.Routing         (RoutingPool, acquireRoutingConnection)
import           Database.Bolty.Session         (Session(..), SessionPool(..), SessionConfig(..),
                                                  BookmarkManager, getBookmarks, updateBookmark)

import           Control.Monad                  (when)
import           Data.IORef                     (IORef, readIORef, writeIORef)


-- ---------------------------------------------------------------------------
-- Bare connection streaming (unchanged)
-- ---------------------------------------------------------------------------

-- | Run a Cypher query and return results as a stream of records.
--
-- Records are yielded one at a time as they arrive from the server,
-- without buffering the entire result set in memory.
--
-- Must be called in @Ready@ or @TXready@ state.
queryStream :: HasCallStack => Connection -> Text -> IO (Stream IO Record)
queryStream conn cypher = queryStreamP conn cypher H.empty


-- | Run a parameterised Cypher query and return results as a stream.
queryStreamP :: HasCallStack => Connection -> Text -> H.HashMap Text PS.Ps -> IO (Stream IO Record)
queryStreamP conn cypher params = do
  _ <- requestResponseRunIO conn cypher params
  pullStream conn


-- | Run a Cypher query and decode each record using a 'RowDecoder'.
-- Throws 'Database.Bolty.Decode.DecodeError' on decode failure.
queryStreamAs :: HasCallStack => RowDecoder a -> Connection -> Text -> IO (Stream IO a)
queryStreamAs decoder conn cypher = queryStreamPAs decoder conn cypher H.empty


-- | Run a parameterised Cypher query and decode each record using a 'RowDecoder'.
-- Throws 'Database.Bolty.Decode.DecodeError' on decode failure.
queryStreamPAs :: HasCallStack => RowDecoder a -> Connection -> Text -> H.HashMap Text PS.Ps -> IO (Stream IO a)
queryStreamPAs decoder conn cypher params = do
  runResp <- requestResponseRunIO conn cypher params
  let columns = successFields runResp
  s <- pullStream conn
  pure $ Stream.mapM (decodeOrThrow decoder columns) s


-- | Decode a single record, throwing 'Database.Bolty.Decode.DecodeError' on failure.
decodeOrThrow :: RowDecoder a -> V.Vector Text -> Record -> IO a
decodeOrThrow decoder columns record =
  case decodeRow decoder columns record of
    Right a  -> pure a
    Left err -> throwIO err


-- | Pull state machine.  @NeedPull@ means we need to send a new PULL
-- message to the server.  @Done@ means the result set is exhausted.
type PullState :: Type
data PullState = NeedPull | Done


-- | Stream records from an in-progress PULL.
--
-- Expects the connection to be in @Streaming@ or @TXstreaming@ state
-- (i.e. after a RUN has been sent and acknowledged). Sends PULL messages
-- and yields each 'Record' as it arrives.  When the server signals
-- completion, the state transitions back to @Ready@ / @TXready@.
pullStream :: HasCallStack => Connection -> IO (Stream IO Record)
pullStream conn = do
  P.requireStateIO conn [Streaming, TXstreaming] "PULL"
  let pull = Pull { n = conn.fetchSize, qid = Nothing }
  P.flushIO conn $ RPull pull
  pure $ Stream.unfoldrM (step conn) NeedPull
  where
    step :: HasCallStack => Connection -> PullState -> IO (Maybe (Record, PullState))
    step _ Done = pure Nothing
    step c NeedPull = do
      response <- P.fetchIO c
      case response of
        RRecord record ->
          pure $ Just (record, NeedPull)

        RSuccess meta -> do
          let hasMore = case H.lookup "has_more" meta of
                          Just hm -> case PS.fromPs hm of
                            Success True -> True
                            _            -> False
                          Nothing -> False
          if hasMore then do
            let pull = Pull { n = c.fetchSize, qid = Nothing }
            P.flushIO c $ RPull pull
            step c NeedPull
          else do
            st <- P.getState c
            P.setState c $ case st of
              TXstreaming -> TXready
              _           -> Ready
            pure Nothing

        RIgnored -> do
          P.reset c
          throwIO ResponseErrorIgnored

        RFailure Failure{code, message} -> do
          P.setState c Failed
          P.reset c
          throwIO $ ResponseErrorFailure code message


-- ---------------------------------------------------------------------------
-- Pool-based streaming (bracketIO)
-- ---------------------------------------------------------------------------

-- | Run a streaming query using a pooled connection. The connection is
-- checked out when the stream starts and returned when it finishes.
poolStream :: HasCallStack => BoltPool -> Text -> Stream IO Record
poolStream pool cypher = poolStreamP pool cypher H.empty


-- | Like 'poolStream' but with query parameters.
poolStreamP :: HasCallStack => BoltPool -> Text -> H.HashMap Text PS.Ps -> Stream IO Record
poolStreamP pool cypher params =
  Stream.bracketIO
    (acquireConnection pool)
    releaseConnection
    (\coc -> Stream.concatEffect $ queryStreamP (cocConnection coc) cypher params)


-- | Like 'poolStream' but decodes each record using a 'RowDecoder'.
poolStreamAs :: HasCallStack => RowDecoder a -> BoltPool -> Text -> Stream IO a
poolStreamAs decoder pool cypher = poolStreamPAs decoder pool cypher H.empty


-- | Like 'poolStreamP' but decodes each record using a 'RowDecoder'.
poolStreamPAs :: HasCallStack => RowDecoder a -> BoltPool -> Text -> H.HashMap Text PS.Ps -> Stream IO a
poolStreamPAs decoder pool cypher params =
  Stream.bracketIO
    (acquireConnection pool)
    releaseConnection
    (\coc -> Stream.concatEffect $ queryStreamPAs decoder (cocConnection coc) cypher params)


-- ---------------------------------------------------------------------------
-- Routing pool streaming (bracketIO)
-- ---------------------------------------------------------------------------

-- | Run a streaming query using a routing pool. Automatically selects
-- a server based on the access mode (read replica or leader).
routingStream :: HasCallStack => RoutingPool -> AccessMode -> Text -> Stream IO Record
routingStream rp mode cypher = routingStreamP rp mode cypher H.empty


-- | Like 'routingStream' but with query parameters.
routingStreamP :: HasCallStack
               => RoutingPool -> AccessMode -> Text -> H.HashMap Text PS.Ps -> Stream IO Record
routingStreamP rp mode cypher params =
  Stream.bracketIO
    (acquireRoutingConnection rp mode)
    releaseConnection
    (\coc -> Stream.concatEffect $ queryStreamP (cocConnection coc) cypher params)


-- | Like 'routingStream' but decodes each record using a 'RowDecoder'.
routingStreamAs :: HasCallStack => RowDecoder a -> RoutingPool -> AccessMode -> Text -> Stream IO a
routingStreamAs decoder rp mode cypher = routingStreamPAs decoder rp mode cypher H.empty


-- | Like 'routingStreamP' but decodes each record using a 'RowDecoder'.
routingStreamPAs :: HasCallStack
                 => RowDecoder a -> RoutingPool -> AccessMode -> Text -> H.HashMap Text PS.Ps -> Stream IO a
routingStreamPAs decoder rp mode cypher params =
  Stream.bracketIO
    (acquireRoutingConnection rp mode)
    releaseConnection
    (\coc -> Stream.concatEffect $ queryStreamPAs decoder (cocConnection coc) cypher params)


-- ---------------------------------------------------------------------------
-- Session streaming (bracketIO with BEGIN/COMMIT)
-- ---------------------------------------------------------------------------

-- | Run a streaming read query inside a managed transaction.
-- Handles BEGIN, COMMIT, bookmark propagation, and connection lifecycle.
sessionReadStream :: HasCallStack => Session -> Text -> Stream IO Record
sessionReadStream session cypher = sessionReadStreamP session cypher H.empty


-- | Like 'sessionReadStream' but with query parameters.
sessionReadStreamP :: HasCallStack => Session -> Text -> H.HashMap Text PS.Ps -> Stream IO Record
sessionReadStreamP session cypher params =
  sessionStreamRaw session ReadAccess cypher params


-- | Run a streaming write query inside a managed transaction.
sessionWriteStream :: HasCallStack => Session -> Text -> Stream IO Record
sessionWriteStream session cypher = sessionWriteStreamP session cypher H.empty


-- | Like 'sessionWriteStream' but with query parameters.
sessionWriteStreamP :: HasCallStack => Session -> Text -> H.HashMap Text PS.Ps -> Stream IO Record
sessionWriteStreamP session cypher params =
  sessionStreamRaw session WriteAccess cypher params


-- | Like 'sessionReadStream' but decodes each record using a 'RowDecoder'.
sessionReadStreamAs :: HasCallStack => RowDecoder a -> Session -> Text -> Stream IO a
sessionReadStreamAs decoder session cypher =
  sessionReadStreamPAs decoder session cypher H.empty


-- | Like 'sessionReadStreamP' but decodes each record using a 'RowDecoder'.
sessionReadStreamPAs :: HasCallStack
                     => RowDecoder a -> Session -> Text -> H.HashMap Text PS.Ps -> Stream IO a
sessionReadStreamPAs decoder session cypher params =
  sessionStreamDecoded decoder session ReadAccess cypher params


-- | Like 'sessionWriteStream' but decodes each record using a 'RowDecoder'.
sessionWriteStreamAs :: HasCallStack => RowDecoder a -> Session -> Text -> Stream IO a
sessionWriteStreamAs decoder session cypher =
  sessionWriteStreamPAs decoder session cypher H.empty


-- | Like 'sessionWriteStreamP' but decodes each record using a 'RowDecoder'.
sessionWriteStreamPAs :: HasCallStack
                      => RowDecoder a -> Session -> Text -> H.HashMap Text PS.Ps -> Stream IO a
sessionWriteStreamPAs decoder session cypher params =
  sessionStreamDecoded decoder session WriteAccess cypher params


-- | Internal: session streaming for raw records.
sessionStreamRaw :: HasCallStack
                 => Session -> AccessMode -> Text -> H.HashMap Text PS.Ps -> Stream IO Record
sessionStreamRaw Session{sPool, sBookmarks, sConfig, sTelemetrySent} mode cypher params =
  Stream.bracketIO (sessionAcquire sPool sBookmarks sConfig sTelemetrySent mode)
                   (sessionCleanup sBookmarks sTelemetrySent)
                   (\coc -> Stream.concatEffect $ queryStreamP (cocConnection coc) cypher params)


-- | Internal: session streaming with record decoding.
sessionStreamDecoded :: HasCallStack
                     => RowDecoder a -> Session -> AccessMode -> Text -> H.HashMap Text PS.Ps -> Stream IO a
sessionStreamDecoded decoder Session{sPool, sBookmarks, sConfig, sTelemetrySent} mode cypher params =
  Stream.bracketIO (sessionAcquire sPool sBookmarks sConfig sTelemetrySent mode)
                   (sessionCleanup sBookmarks sTelemetrySent)
                   (\coc -> Stream.concatEffect $ queryStreamPAs decoder (cocConnection coc) cypher params)


-- | Acquire a session connection and begin a transaction.
sessionAcquire :: SessionPool -> BookmarkManager -> SessionConfig -> IORef Bool
               -> AccessMode -> IO CheckedOutConnection
sessionAcquire sPool sBookmarks sConfig _telRef mode = do
  let db = sessionDatabase sConfig
  coc <- acquireSessionConnection sPool mode
  let conn = cocConnection coc
  bms <- getBookmarks sBookmarks
  P.beginTx conn $ Begin (V.fromList bms) Nothing H.empty mode db Nothing
  pure coc


-- | Commit the transaction and release the connection.
sessionCleanup :: BookmarkManager -> IORef Bool -> CheckedOutConnection -> IO ()
sessionCleanup sBookmarks sTelemetrySent coc = do
  let conn = cocConnection coc
  result <- try $ do
    mbBookmark <- P.commitTx conn
    case mbBookmark of
      Just bm -> updateBookmark sBookmarks bm
      Nothing -> pure ()
    sent <- readIORef sTelemetrySent
    when (not sent) $ do
      writeIORef sTelemetrySent True
      P.sendTelemetry conn ManagedTransactions
  case result of
    Right () -> releaseConnection coc
    Left (_ :: SomeException) -> releaseConnectionOnError coc


-- | Acquire a connection based on session pool type.
acquireSessionConnection :: SessionPool -> AccessMode -> IO CheckedOutConnection
acquireSessionConnection (DirectPool pool) _mode = acquireConnection pool
acquireSessionConnection (RoutedPool rp) mode = acquireRoutingConnection rp mode


-- | Extract the database field from SessionConfig (avoids ambiguous record access).
sessionDatabase :: SessionConfig -> Maybe Text
sessionDatabase SessionConfig{database} = database