packages feed

bolty 0.1.1.0 → 0.2.0.0

raw patch · 15 files changed

+1156/−71 lines, 15 filesdep ~aesondep ~bytestringPVP ok

version bump matches the API change (PVP)

Dependency ranges changed: aeson, bytestring

API changes (from Hackage documentation)

- Database.Bolty: instance Control.Monad.IO.Class.MonadIO Database.Bolty.BoltM
- Database.Bolty: instance GHC.Base.Applicative Database.Bolty.BoltM
- Database.Bolty: instance GHC.Base.Functor Database.Bolty.BoltM
- Database.Bolty: instance GHC.Base.Monad Database.Bolty.BoltM
- Database.Bolty.Connection.Type: instance GHC.Exception.Type.Exception Database.Bolty.Connection.Type.Error
- Database.Bolty.Connection.Type: instance GHC.Generics.Generic Database.Bolty.Connection.Type.Scheme
- Database.Bolty.Connection.Type: instance GHC.Show.Show Database.Bolty.Connection.Type.Error
- Database.Bolty.Connection.Type: instance GHC.Show.Show Database.Bolty.Connection.Type.ServerState
- Database.Bolty.Decode: instance GHC.Base.Applicative Database.Bolty.Decode.Decode
- Database.Bolty.Decode: instance GHC.Base.Applicative Database.Bolty.Decode.RowDecoder
- Database.Bolty.Decode: instance GHC.Base.Functor Database.Bolty.Decode.Decode
- Database.Bolty.Decode: instance GHC.Base.Functor Database.Bolty.Decode.RowDecoder
- Database.Bolty.Decode: instance GHC.Base.Monad Database.Bolty.Decode.Decode
- Database.Bolty.Decode: instance GHC.Exception.Type.Exception Database.Bolty.Decode.DecodeError
- Database.Bolty.Decode: instance GHC.Show.Show Database.Bolty.Decode.DecodeError
- Database.Bolty.Logging: instance GHC.Show.Show Database.Bolty.Logging.QueryLog
- Database.Bolty.Message.Request: instance GHC.Generics.Generic Database.Bolty.Message.Request.Begin
- Database.Bolty.Message.Request: instance GHC.Generics.Generic Database.Bolty.Message.Request.Discard
- Database.Bolty.Message.Request: instance GHC.Generics.Generic Database.Bolty.Message.Request.Hello
- Database.Bolty.Message.Request: instance GHC.Generics.Generic Database.Bolty.Message.Request.Logon
- Database.Bolty.Message.Request: instance GHC.Generics.Generic Database.Bolty.Message.Request.Pull
- Database.Bolty.Message.Request: instance GHC.Generics.Generic Database.Bolty.Message.Request.Request
- Database.Bolty.Message.Request: instance GHC.Generics.Generic Database.Bolty.Message.Request.RunAutoCommitTransaction
- Database.Bolty.Message.Request: instance GHC.Generics.Generic Database.Bolty.Message.Request.RunExplicitTransaction
- Database.Bolty.Message.Request: instance GHC.Show.Show Database.Bolty.Message.Request.TelemetryApi
- Database.Bolty.Message.Response: instance GHC.Show.Show Database.Bolty.Message.Response.RoutingTable
- Database.Bolty.Notification: instance GHC.Show.Show Database.Bolty.Notification.Notification
- Database.Bolty.Notification: instance GHC.Show.Show Database.Bolty.Notification.Position
- Database.Bolty.Notification: instance GHC.Show.Show Database.Bolty.Notification.Severity
- Database.Bolty.Plan: instance GHC.Show.Show Database.Bolty.Plan.PlanNode
- Database.Bolty.Plan: instance GHC.Show.Show Database.Bolty.Plan.ProfileNode
- Database.Bolty.Pool: instance GHC.Show.Show Database.Bolty.Pool.PoolCounters
- Database.Bolty.Pool: instance GHC.Show.Show Database.Bolty.Pool.RetryConfig
- Database.Bolty.Pool: instance GHC.Show.Show Database.Bolty.Pool.ValidationStrategy
- Database.Bolty.ResultSet: instance GHC.Show.Show Database.Bolty.ResultSet.ResultSet
- Database.Bolty.Routing: ReadAccess :: AccessMode
- Database.Bolty.Routing: WriteAccess :: AccessMode
- Database.Bolty.Routing: data AccessMode
- Database.Bolty.Routing: instance GHC.Classes.Eq Database.Bolty.Routing.AccessMode
- Database.Bolty.Routing: instance GHC.Show.Show Database.Bolty.Routing.AccessMode
- Database.Bolty.Stats: instance GHC.Show.Show Database.Bolty.Stats.QueryStats
- Database.Bolty.Value.Type: instance GHC.Generics.Generic Database.Bolty.Value.Type.Bolt
- Database.Bolty.Value.Type: instance GHC.Generics.Generic Database.Bolty.Value.Type.Date
- Database.Bolty.Value.Type: instance GHC.Generics.Generic Database.Bolty.Value.Type.DateTime
- Database.Bolty.Value.Type: instance GHC.Generics.Generic Database.Bolty.Value.Type.DateTimeZoneId
- Database.Bolty.Value.Type: instance GHC.Generics.Generic Database.Bolty.Value.Type.Duration
- Database.Bolty.Value.Type: instance GHC.Generics.Generic Database.Bolty.Value.Type.LocalDateTime
- Database.Bolty.Value.Type: instance GHC.Generics.Generic Database.Bolty.Value.Type.LocalTime
- Database.Bolty.Value.Type: instance GHC.Generics.Generic Database.Bolty.Value.Type.Node
- Database.Bolty.Value.Type: instance GHC.Generics.Generic Database.Bolty.Value.Type.Path
- Database.Bolty.Value.Type: instance GHC.Generics.Generic Database.Bolty.Value.Type.Point2D
- Database.Bolty.Value.Type: instance GHC.Generics.Generic Database.Bolty.Value.Type.Point3D
- Database.Bolty.Value.Type: instance GHC.Generics.Generic Database.Bolty.Value.Type.Relationship
- Database.Bolty.Value.Type: instance GHC.Generics.Generic Database.Bolty.Value.Type.Time
- Database.Bolty.Value.Type: instance GHC.Generics.Generic Database.Bolty.Value.Type.UnboundRelationship
- Database.Bolty.Value.Type: instance GHC.Show.Show Database.Bolty.Value.Type.Bolt
- Database.Bolty.Value.Type: instance GHC.Show.Show Database.Bolty.Value.Type.Date
- Database.Bolty.Value.Type: instance GHC.Show.Show Database.Bolty.Value.Type.DateTime
- Database.Bolty.Value.Type: instance GHC.Show.Show Database.Bolty.Value.Type.DateTimeZoneId
- Database.Bolty.Value.Type: instance GHC.Show.Show Database.Bolty.Value.Type.Duration
- Database.Bolty.Value.Type: instance GHC.Show.Show Database.Bolty.Value.Type.LocalDateTime
- Database.Bolty.Value.Type: instance GHC.Show.Show Database.Bolty.Value.Type.LocalTime
- Database.Bolty.Value.Type: instance GHC.Show.Show Database.Bolty.Value.Type.Node
- Database.Bolty.Value.Type: instance GHC.Show.Show Database.Bolty.Value.Type.Path
- Database.Bolty.Value.Type: instance GHC.Show.Show Database.Bolty.Value.Type.Point2D
- Database.Bolty.Value.Type: instance GHC.Show.Show Database.Bolty.Value.Type.Point3D
- Database.Bolty.Value.Type: instance GHC.Show.Show Database.Bolty.Value.Type.Relationship
- Database.Bolty.Value.Type: instance GHC.Show.Show Database.Bolty.Value.Type.Time
- Database.Bolty.Value.Type: instance GHC.Show.Show Database.Bolty.Value.Type.UnboundRelationship
+ Database.Bolty: ReadAbortedByWrite :: !QueryStats -> ReadOutcome
+ Database.Bolty: ReadModeViolation :: !QueryStats -> ReadModeViolation
+ Database.Bolty: ReadOK :: !ResultSet -> !QueryMeta -> ReadOutcome
+ Database.Bolty: WriteAbortedMismatch :: !Int64 -> !Int64 -> !Maybe QueryStats -> WriteOutcome
+ Database.Bolty: WriteCommitted :: !ResultSet -> !QueryMeta -> WriteOutcome
+ Database.Bolty: WriteCountMismatch :: !Int64 -> !Int64 -> !Maybe QueryStats -> WriteCountMismatch
+ Database.Bolty: data ReadModeViolation
+ Database.Bolty: data ReadOutcome
+ Database.Bolty: data WriteCountMismatch
+ Database.Bolty: data WriteOutcome
+ Database.Bolty: describeReadOutcome :: ReadOutcome -> Text
+ Database.Bolty: describeWriteOutcome :: WriteOutcome -> Text
+ Database.Bolty: formatStatsLine :: QueryStats -> Text
+ Database.Bolty: instance GHC.Internal.Base.Applicative Database.Bolty.BoltM
+ Database.Bolty: instance GHC.Internal.Base.Functor Database.Bolty.BoltM
+ Database.Bolty: instance GHC.Internal.Base.Monad Database.Bolty.BoltM
+ Database.Bolty: instance GHC.Internal.Control.Monad.IO.Class.MonadIO Database.Bolty.BoltM
+ Database.Bolty: parsePs :: Value -> Parser Ps
+ Database.Bolty: propsFromAeson :: Value -> Parser (HashMap Text Ps)
+ Database.Bolty: propsToAeson :: HashMap Text Ps -> Value
+ Database.Bolty: psToAeson :: Ps -> Value
+ Database.Bolty: renderPlan :: Int -> PlanNode -> Text
+ Database.Bolty: totalAffected :: Maybe QueryStats -> Int64
+ Database.Bolty: withConfirmedWriteTransaction :: Connection -> Text -> HashMap Text Ps -> Int64 -> IO (Either SomeException WriteOutcome)
+ Database.Bolty: withConfirmedWriteTransaction' :: BoltPool -> Text -> HashMap Text Ps -> Int64 -> IO (Either SomeException WriteOutcome)
+ Database.Bolty: withReadOnlyTransaction :: Connection -> Text -> HashMap Text Ps -> IO (Either SomeException ReadOutcome)
+ Database.Bolty: withReadOnlyTransaction' :: BoltPool -> Text -> HashMap Text Ps -> IO (Either SomeException ReadOutcome)
+ Database.Bolty: withReadTransaction :: HasCallStack => Connection -> (Connection -> IO a) -> IO a
+ Database.Bolty: withReadTransaction' :: HasCallStack => BoltPool -> (Connection -> IO a) -> IO a
+ Database.Bolty: withRetryTransactionMode :: HasCallStack => AccessMode -> RetryConfig -> Connection -> (Connection -> IO a) -> IO a
+ Database.Bolty: withTxMode :: HasCallStack => AccessMode -> Connection -> (Connection -> IO a) -> IO a
+ Database.Bolty.AccessMode: ReadAccess :: AccessMode
+ Database.Bolty.AccessMode: WriteAccess :: AccessMode
+ Database.Bolty.AccessMode: data AccessMode
+ Database.Bolty.AccessMode: instance Data.PackStream.Ps.PackStream Database.Bolty.AccessMode.AccessMode
+ Database.Bolty.AccessMode: instance GHC.Classes.Eq Database.Bolty.AccessMode.AccessMode
+ Database.Bolty.AccessMode: instance GHC.Classes.Ord Database.Bolty.AccessMode.AccessMode
+ Database.Bolty.AccessMode: instance GHC.Internal.Enum.Bounded Database.Bolty.AccessMode.AccessMode
+ Database.Bolty.AccessMode: instance GHC.Internal.Enum.Enum Database.Bolty.AccessMode.AccessMode
+ Database.Bolty.AccessMode: instance GHC.Internal.Generics.Generic Database.Bolty.AccessMode.AccessMode
+ Database.Bolty.AccessMode: instance GHC.Internal.Show.Show Database.Bolty.AccessMode.AccessMode
+ Database.Bolty.Admin: ReadAbortedByWrite :: !QueryStats -> ReadOutcome
+ Database.Bolty.Admin: ReadModeViolation :: !QueryStats -> ReadModeViolation
+ Database.Bolty.Admin: ReadOK :: !ResultSet -> !QueryMeta -> ReadOutcome
+ Database.Bolty.Admin: WriteAbortedMismatch :: !Int64 -> !Int64 -> !Maybe QueryStats -> WriteOutcome
+ Database.Bolty.Admin: WriteCommitted :: !ResultSet -> !QueryMeta -> WriteOutcome
+ Database.Bolty.Admin: WriteCountMismatch :: !Int64 -> !Int64 -> !Maybe QueryStats -> WriteCountMismatch
+ Database.Bolty.Admin: data ReadModeViolation
+ Database.Bolty.Admin: data ReadOutcome
+ Database.Bolty.Admin: data WriteCountMismatch
+ Database.Bolty.Admin: data WriteOutcome
+ Database.Bolty.Admin: describeReadOutcome :: ReadOutcome -> Text
+ Database.Bolty.Admin: describeWriteOutcome :: WriteOutcome -> Text
+ Database.Bolty.Admin: instance GHC.Internal.Exception.Type.Exception Database.Bolty.Admin.ReadModeViolation
+ Database.Bolty.Admin: instance GHC.Internal.Exception.Type.Exception Database.Bolty.Admin.WriteCountMismatch
+ Database.Bolty.Admin: instance GHC.Internal.Show.Show Database.Bolty.Admin.ReadModeViolation
+ Database.Bolty.Admin: instance GHC.Internal.Show.Show Database.Bolty.Admin.WriteCountMismatch
+ Database.Bolty.Admin: totalAffected :: Maybe QueryStats -> Int64
+ Database.Bolty.Admin: withConfirmedWriteTransaction :: Connection -> Text -> HashMap Text Ps -> Int64 -> IO (Either SomeException WriteOutcome)
+ Database.Bolty.Admin: withConfirmedWriteTransaction' :: BoltPool -> Text -> HashMap Text Ps -> Int64 -> IO (Either SomeException WriteOutcome)
+ Database.Bolty.Admin: withReadOnlyTransaction :: Connection -> Text -> HashMap Text Ps -> IO (Either SomeException ReadOutcome)
+ Database.Bolty.Admin: withReadOnlyTransaction' :: BoltPool -> Text -> HashMap Text Ps -> IO (Either SomeException ReadOutcome)
+ Database.Bolty.Connection: withTxMode :: HasCallStack => AccessMode -> Connection -> (Connection -> IO a) -> IO a
+ Database.Bolty.Connection.Type: instance GHC.Internal.Exception.Type.Exception Database.Bolty.Connection.Type.Error
+ Database.Bolty.Connection.Type: instance GHC.Internal.Generics.Generic Database.Bolty.Connection.Type.Scheme
+ Database.Bolty.Connection.Type: instance GHC.Internal.Show.Show Database.Bolty.Connection.Type.Error
+ Database.Bolty.Connection.Type: instance GHC.Internal.Show.Show Database.Bolty.Connection.Type.ServerState
+ Database.Bolty.Decode: instance GHC.Internal.Base.Applicative Database.Bolty.Decode.Decode
+ Database.Bolty.Decode: instance GHC.Internal.Base.Applicative Database.Bolty.Decode.RowDecoder
+ Database.Bolty.Decode: instance GHC.Internal.Base.Functor Database.Bolty.Decode.Decode
+ Database.Bolty.Decode: instance GHC.Internal.Base.Functor Database.Bolty.Decode.RowDecoder
+ Database.Bolty.Decode: instance GHC.Internal.Base.Monad Database.Bolty.Decode.Decode
+ Database.Bolty.Decode: instance GHC.Internal.Exception.Type.Exception Database.Bolty.Decode.DecodeError
+ Database.Bolty.Decode: instance GHC.Internal.Show.Show Database.Bolty.Decode.DecodeError
+ Database.Bolty.Logging: instance GHC.Internal.Show.Show Database.Bolty.Logging.QueryLog
+ Database.Bolty.Message.Request: instance GHC.Internal.Generics.Generic Database.Bolty.Message.Request.Begin
+ Database.Bolty.Message.Request: instance GHC.Internal.Generics.Generic Database.Bolty.Message.Request.Discard
+ Database.Bolty.Message.Request: instance GHC.Internal.Generics.Generic Database.Bolty.Message.Request.Hello
+ Database.Bolty.Message.Request: instance GHC.Internal.Generics.Generic Database.Bolty.Message.Request.Logon
+ Database.Bolty.Message.Request: instance GHC.Internal.Generics.Generic Database.Bolty.Message.Request.Pull
+ Database.Bolty.Message.Request: instance GHC.Internal.Generics.Generic Database.Bolty.Message.Request.Request
+ Database.Bolty.Message.Request: instance GHC.Internal.Generics.Generic Database.Bolty.Message.Request.RunAutoCommitTransaction
+ Database.Bolty.Message.Request: instance GHC.Internal.Generics.Generic Database.Bolty.Message.Request.RunExplicitTransaction
+ Database.Bolty.Message.Request: instance GHC.Internal.Show.Show Database.Bolty.Message.Request.TelemetryApi
+ Database.Bolty.Message.Response: instance GHC.Internal.Show.Show Database.Bolty.Message.Response.QueryMeta
+ Database.Bolty.Message.Response: instance GHC.Internal.Show.Show Database.Bolty.Message.Response.RoutingTable
+ Database.Bolty.Notification: instance GHC.Internal.Show.Show Database.Bolty.Notification.Notification
+ Database.Bolty.Notification: instance GHC.Internal.Show.Show Database.Bolty.Notification.Position
+ Database.Bolty.Notification: instance GHC.Internal.Show.Show Database.Bolty.Notification.Severity
+ Database.Bolty.Plan: instance GHC.Internal.Show.Show Database.Bolty.Plan.PlanNode
+ Database.Bolty.Plan: instance GHC.Internal.Show.Show Database.Bolty.Plan.ProfileNode
+ Database.Bolty.Plan: renderPlan :: Int -> PlanNode -> Text
+ Database.Bolty.Pool: instance GHC.Internal.Show.Show Database.Bolty.Pool.PoolCounters
+ Database.Bolty.Pool: instance GHC.Internal.Show.Show Database.Bolty.Pool.RetryConfig
+ Database.Bolty.Pool: instance GHC.Internal.Show.Show Database.Bolty.Pool.ValidationStrategy
+ Database.Bolty.ResultSet: instance GHC.Internal.Show.Show Database.Bolty.ResultSet.ResultSet
+ Database.Bolty.Stats: formatStatsLine :: QueryStats -> Text
+ Database.Bolty.Stats: instance GHC.Internal.Show.Show Database.Bolty.Stats.QueryStats
+ Database.Bolty.Value.Type: instance GHC.Internal.Generics.Generic Database.Bolty.Value.Type.Bolt
+ Database.Bolty.Value.Type: instance GHC.Internal.Generics.Generic Database.Bolty.Value.Type.Date
+ Database.Bolty.Value.Type: instance GHC.Internal.Generics.Generic Database.Bolty.Value.Type.DateTime
+ Database.Bolty.Value.Type: instance GHC.Internal.Generics.Generic Database.Bolty.Value.Type.DateTimeZoneId
+ Database.Bolty.Value.Type: instance GHC.Internal.Generics.Generic Database.Bolty.Value.Type.Duration
+ Database.Bolty.Value.Type: instance GHC.Internal.Generics.Generic Database.Bolty.Value.Type.LocalDateTime
+ Database.Bolty.Value.Type: instance GHC.Internal.Generics.Generic Database.Bolty.Value.Type.LocalTime
+ Database.Bolty.Value.Type: instance GHC.Internal.Generics.Generic Database.Bolty.Value.Type.Node
+ Database.Bolty.Value.Type: instance GHC.Internal.Generics.Generic Database.Bolty.Value.Type.Path
+ Database.Bolty.Value.Type: instance GHC.Internal.Generics.Generic Database.Bolty.Value.Type.Point2D
+ Database.Bolty.Value.Type: instance GHC.Internal.Generics.Generic Database.Bolty.Value.Type.Point3D
+ Database.Bolty.Value.Type: instance GHC.Internal.Generics.Generic Database.Bolty.Value.Type.Relationship
+ Database.Bolty.Value.Type: instance GHC.Internal.Generics.Generic Database.Bolty.Value.Type.Time
+ Database.Bolty.Value.Type: instance GHC.Internal.Generics.Generic Database.Bolty.Value.Type.UnboundRelationship
+ Database.Bolty.Value.Type: instance GHC.Internal.Show.Show Database.Bolty.Value.Type.Bolt
+ Database.Bolty.Value.Type: instance GHC.Internal.Show.Show Database.Bolty.Value.Type.Date
+ Database.Bolty.Value.Type: instance GHC.Internal.Show.Show Database.Bolty.Value.Type.DateTime
+ Database.Bolty.Value.Type: instance GHC.Internal.Show.Show Database.Bolty.Value.Type.DateTimeZoneId
+ Database.Bolty.Value.Type: instance GHC.Internal.Show.Show Database.Bolty.Value.Type.Duration
+ Database.Bolty.Value.Type: instance GHC.Internal.Show.Show Database.Bolty.Value.Type.LocalDateTime
+ Database.Bolty.Value.Type: instance GHC.Internal.Show.Show Database.Bolty.Value.Type.LocalTime
+ Database.Bolty.Value.Type: instance GHC.Internal.Show.Show Database.Bolty.Value.Type.Node
+ Database.Bolty.Value.Type: instance GHC.Internal.Show.Show Database.Bolty.Value.Type.Path
+ Database.Bolty.Value.Type: instance GHC.Internal.Show.Show Database.Bolty.Value.Type.Point2D
+ Database.Bolty.Value.Type: instance GHC.Internal.Show.Show Database.Bolty.Value.Type.Point3D
+ Database.Bolty.Value.Type: instance GHC.Internal.Show.Show Database.Bolty.Value.Type.Relationship
+ Database.Bolty.Value.Type: instance GHC.Internal.Show.Show Database.Bolty.Value.Type.Time
+ Database.Bolty.Value.Type: instance GHC.Internal.Show.Show Database.Bolty.Value.Type.UnboundRelationship
+ Database.Bolty.Value.Type: parsePs :: Value -> Parser Ps
+ Database.Bolty.Value.Type: propsFromAeson :: Value -> Parser (HashMap Text Ps)
+ Database.Bolty.Value.Type: propsToAeson :: HashMap Text Ps -> Value
+ Database.Bolty.Value.Type: psToAeson :: Ps -> Value
- Database.Bolty.Message.Request: Begin :: !Vector Text -> !Maybe Int64 -> !HashMap Text Ps -> !Char -> !Maybe Text -> !Maybe Text -> Begin
+ Database.Bolty.Message.Request: Begin :: !Vector Text -> !Maybe Int64 -> !HashMap Text Ps -> !AccessMode -> !Maybe Text -> !Maybe Text -> Begin
- Database.Bolty.Message.Request: RunAutoCommitTransaction :: !Text -> !HashMap Text Ps -> RunExtra -> Vector Text -> !Maybe Int64 -> !HashMap Text Ps -> !Char -> !Maybe Text -> !Maybe Text -> RunAutoCommitTransaction
+ Database.Bolty.Message.Request: RunAutoCommitTransaction :: !Text -> !HashMap Text Ps -> RunExtra -> Vector Text -> !Maybe Int64 -> !HashMap Text Ps -> !AccessMode -> !Maybe Text -> !Maybe Text -> RunAutoCommitTransaction
- Database.Bolty.Message.Request: RunExtra :: !Vector Text -> !Maybe Int64 -> !HashMap Text Ps -> !Char -> !Maybe Text -> !Maybe Text -> RunExtra
+ Database.Bolty.Message.Request: RunExtra :: !Vector Text -> !Maybe Int64 -> !HashMap Text Ps -> !AccessMode -> !Maybe Text -> !Maybe Text -> RunExtra
- Database.Bolty.Message.Request: [mode] :: RunExtra -> !Char
+ Database.Bolty.Message.Request: [mode] :: RunExtra -> !AccessMode

Files

bolty.cabal view
@@ -5,7 +5,7 @@ -- see: https://github.com/sol/hpack  name:           bolty-version:        0.1.1.0+version:        0.2.0.0 synopsis:       Haskell driver for Neo4j (BOLT protocol 4.4-5.4) description:    Native Haskell driver for Neo4j graph database using the BOLT protocol.                 Supports BOLT versions 4.4 through 5.4 with connection pooling,@@ -37,6 +37,7 @@ library   exposed-modules:       Database.Bolty+      Database.Bolty.AccessMode       Database.Bolty.Decode       Database.Bolty.Pool       Database.Bolty.Record@@ -45,6 +46,7 @@       Database.Bolty.Session       Database.Bolty.Logging       Database.Bolty.Notification+      Database.Bolty.Admin       Database.Bolty.Plan       Database.Bolty.Stats       Database.Bolty.Connection@@ -196,10 +198,10 @@   ghc-options: -Weverything -Wno-all-missed-specialisations -Wno-implicit-prelude -Wno-missed-specialisations -Wno-missing-import-lists -Wno-missing-safe-haskell-mode -Wno-name-shadowing -Wno-prepositive-qualified-module -Wno-safe -Wno-unsafe -threaded -rtsopts -with-rtsopts=-N -Wno-missing-export-lists -Wno-missing-local-signatures -Wno-monomorphism-restriction -Wno-unused-imports -Wno-unused-packages   build-depends:       QuickCheck-    , aeson >=2.1 && <2.3+    , aeson     , base >=4.18 && <5     , bolty-    , bytestring >=0.11 && <0.13+    , bytestring     , crypton-connection >=0.3 && <0.5     , data-default >=0.7 && <0.9     , extra >=1.7 && <1.9
changelog.md view
@@ -6,6 +6,24 @@ and this project adheres to the [Haskell Package Versioning Policy](https://pvp.haskell.org/). +## 0.2.0.0++- New `Database.Bolty.AccessMode` module: `AccessMode = ReadAccess | WriteAccess` with a single `PackStream` instance. Re-exported from `Database.Bolty`.+- New `Database.Bolty.Admin` module with gated transaction primitives for admin tools, CLIs, and migrations: `withReadOnlyTransaction` and `withConfirmedWriteTransaction` (plus pool variants), `ReadOutcome` / `WriteOutcome` types, `totalAffected`, `describeReadOutcome` / `describeWriteOutcome`.+- New transaction primitives in `Database.Bolty`: `withReadTransaction` and `withReadTransaction'` (un-gated read tx with cluster routing-to-follower and server-side enforcement), public `withTxMode :: AccessMode -> ...`, `withRetryTransactionMode :: AccessMode -> ...`.+- New public Aeson interop: `parsePs`, `psToAeson`, `propsToAeson`, `propsFromAeson`.+- New exports `renderPlan` (from `Database.Bolty.Plan`) and `formatStatsLine` (from `Database.Bolty.Stats`).+- New `Show QueryMeta` instance.+- **Breaking**: `Begin.mode`, `RunExtra.mode`, `RunAutoCommitTransaction.mode` are now `AccessMode` instead of `Char`. Use `ReadAccess` / `WriteAccess` instead of `'r'` / `'w'`.+- **Breaking**: `withTxMode` and `withRetryTransactionMode` take `AccessMode` instead of `Char`.+- **Breaking**: `Database.Bolty.Tx` module renamed to `Database.Bolty.Admin`. `totalAffected` moved from `Database.Bolty.Stats` to `Database.Bolty.Admin` (re-export from `Database.Bolty` is unchanged).+- **Breaking**: `AccessMode(..)` no longer re-exported from `Database.Bolty.Routing`. Import from `Database.Bolty.AccessMode` or `Database.Bolty`.+- **Breaking**: Internal helper `fromPsMode` removed.+- Fix: `Begin.fromPs`, `RunExtra.fromPs`, and the autocommit RUN decoder now accept wire dicts where the optional `mode` key is absent (defaulting to `WriteAccess` per the Bolt spec). Previously bolty rejected spec-compliant peers that omitted the default.+- Fix: `withReadOnlyTransaction` opens BEGIN with `mode='r'` (was `mode='w'`) for cluster read routing and server-side write rejection on Neo4j 5+.+- Fix: pool-variant transaction helpers capture pool-acquire failures in the returned `Either` instead of propagating them as exceptions.+- 15 new direct `AccessMode` unit tests covering encode/decode round-trips, rejection of invalid `Ps` shapes, `Bounded` / `Enum` exhaustion, and parent-message decoders for the spec-default and rejection paths on `Begin`, `RunExtra`, and the autocommit `RUN`. bolty-test: 320 → 335 passing.+ ## 0.1.0.2  - Fix pool crash on machines with more than 10 CPU cores (set numStripes to 1)
integration/Main.hs view
@@ -252,7 +252,34 @@         Nothing -> expectationFailure "Expected 'alice'"       liftIO $ wp $ \p -> queryIO p "MATCH (n:TestTxParam) DELETE n" >> pure () +    it "withReadTransaction runs a read query" $ do+      -- The un-gated read tx primitive: just opens BEGIN with mode='r',+      -- runs the action, commits. No post-hoc stats inspection.+      result <- liftIO $ wp $ \p ->+        withReadTransaction p $ \conn ->+          queryIO conn "RETURN 42 AS n"+      case asInt (V.head $ V.head result) of+        Just n  -> n `shouldBe` 42+        Nothing -> expectationFailure "Expected 42" +    it "withReadTransaction rejects writes upfront (AccessMode)" $ do+      -- mode='r' makes the server reject any write attempt before it+      -- runs. The exception type varies by Neo4j version; we just check+      -- that an exception was thrown and no row landed.+      r :: Either SomeException () <- liftIO $ try $+        wp $ \p ->+          withReadTransaction p $ \conn ->+            queryIO conn "CREATE (:TestReadTxReject {tag: 'nope'})" >> pure ()+      case r of+        Left _  -> pure ()+        Right _ -> expectationFailure "Expected an AccessMode error from server"+      count <- liftIO $ wp $ \p ->+        queryIO p "MATCH (n:TestReadTxReject) RETURN count(n) AS c"+      case asInt (V.head $ V.head count) of+        Just n  -> n `shouldBe` 0+        Nothing -> expectationFailure "Expected 0"++   describe (label ++ " - Graph type queries") $ do      it "returns a Node" $ do@@ -1373,6 +1400,173 @@   -- ================================================================+-- Admin / sandbox transaction integration tests (Database.Bolty.Admin)+--+-- The whole point of these wrappers is the tx-rollback behaviour, which+-- only the bolt server can verify -- a unit test that mocks the+-- connection can't observe whether the writes actually became durable.+-- These cases set up a graph fixture, run the gated tx, then poll the+-- database to assert state.+-- ================================================================++adminTestSuite :: TopSpec+adminTestSuite = do+  let wp :: (Connection -> IO a) -> IO a+      wp = mkWithConn neo4j5Config+      -- Unique label so this suite doesn't trip over other tests' fixtures.+      lbl = "BoltyTxTest"+      cleanup = liftIO $ wp $ \p ->+        queryIO p ("MATCH (n:" <> lbl <> ") DETACH DELETE n") >> pure ()+      countNodes = liftIO $ wp $ \p -> do+        r <- queryIO p ("MATCH (n:" <> lbl <> ") RETURN count(n) AS c")+        case asInt (V.head $ V.head r) of+          Just n  -> pure n+          Nothing -> error "expected an int"++  describe "Database.Bolty.Admin (real Neo4j)" $ do++    it "withReadOnlyTransaction returns ReadOK for read-only Cypher" $ do+      cleanup+      outcome <- liftIO $ wp $ \conn ->+        withReadOnlyTransaction conn "RETURN 1 AS x" H.empty+      case outcome of+        Right (ReadOK _ _) -> pure ()+        Right other        -> expectationFailure $+          "expected ReadOK, got " <> T.unpack (describeReadOutcome other)+        Left e             -> expectationFailure $ "tx error: " <> show e++    -- A read tx that attempts a write must never commit. With mode='r',+    -- Neo4j 5 rejects the write upfront with an AccessMode error+    -- ('Left'). Older versions, or procedure-style writes that bypass+    -- access-mode enforcement, would surface as 'ReadAbortedByWrite'.+    -- Both outcomes are correct; only an accidental commit would be a bug.+    it "CREATE inside read tx never commits" $ do+      cleanup+      before <- countNodes+      before `shouldBe` 0+      outcome <- liftIO $ wp $ \conn ->+        withReadOnlyTransaction conn+          ("CREATE (:" <> lbl <> " {tag: 'should-not-persist'}) RETURN 1") H.empty+      assertReadDidNotCommit outcome+      after <- countNodes+      after `shouldBe` 0++    it "DELETE inside read tx leaves the row intact" $ do+      cleanup+      liftIO $ wp $ \p -> queryIO p ("CREATE (:" <> lbl <> " {a: 1})") >> pure ()+      before <- countNodes+      before `shouldBe` 1+      outcome <- liftIO $ wp $ \conn ->+        withReadOnlyTransaction conn+          ("MATCH (n:" <> lbl <> ") DELETE n RETURN count(n)") H.empty+      assertReadDidNotCommit outcome+      after <- countNodes+      after `shouldBe` 1+      cleanup++    it "withConfirmedWriteTransaction commits when the count matches" $ do+      cleanup+      -- CREATE (:X {a:1}) reports affected = node + property + label = 3.+      outcome <- liftIO $ wp $ \conn ->+        withConfirmedWriteTransaction conn+          ("CREATE (:" <> lbl <> " {a: 1})") H.empty 3+      case outcome of+        Right (WriteCommitted _ _) -> pure ()+        Right other                -> expectationFailure $+          "expected WriteCommitted, got " <> T.unpack (describeWriteOutcome other)+        Left e                     -> expectationFailure $ show e+      after <- countNodes+      after `shouldBe` 1+      cleanup++    it "withConfirmedWriteTransaction rolls back on count mismatch" $ do+      cleanup+      outcome <- liftIO $ wp $ \conn ->+        withConfirmedWriteTransaction conn+          ("CREATE (:" <> lbl <> " {a: 99})") H.empty 99+      case outcome of+        Right (WriteAbortedMismatch ex actual _) -> do+          ex `shouldBe` 99+          actual `shouldBe` 3+        Right other                              -> expectationFailure $+          "expected WriteAbortedMismatch, got " <> T.unpack (describeWriteOutcome other)+        Left e                                   -> expectationFailure $ show e+      after <- countNodes+      after `shouldBe` 0++    it "pool variant returns a ReadOK for a simple read" $ do+      cleanup+      cfg <- liftIO $ mkGetConfig neo4j5Config+      pool <- liftIO $ createPool cfg defaultPoolConfig+      outcome <- liftIO $ withReadOnlyTransaction' pool "RETURN 1 AS x" H.empty+      liftIO $ destroyPool pool+      case outcome of+        Right (ReadOK _ _) -> pure ()+        Right other        -> expectationFailure $+          "expected ReadOK, got " <> T.unpack (describeReadOutcome other)+        Left e             -> expectationFailure $ show e++    it "pool variant of confirmed write commits when count matches" $ do+      cleanup+      cfg <- liftIO $ mkGetConfig neo4j5Config+      pool <- liftIO $ createPool cfg defaultPoolConfig+      outcome <- liftIO $ withConfirmedWriteTransaction' pool+        ("CREATE (:" <> lbl <> " {a: 1})") H.empty 3+      liftIO $ destroyPool pool+      case outcome of+        Right (WriteCommitted _ _) -> pure ()+        Right other                -> expectationFailure $+          "expected WriteCommitted, got " <> T.unpack (describeWriteOutcome other)+        Left e                     -> expectationFailure $ show e+      after <- countNodes+      after `shouldBe` 1+      cleanup++    it "pool variant captures pool acquire failures in Left" $ do+      -- Build a pool pointing at a port nothing's listening on. The+      -- subsequent withReadOnlyTransaction' must surface the connect error+      -- as Left, not throw -- this is the contract added by the try-wrap.+      let badConfig = pure def+            { host = "127.0.0.1"+            , port = 65500  -- unprivileged port; nothing listening here+            , scheme = None+            , use_tls = False+            }+      cfg <- liftIO $ mkGetConfig badConfig+      pool <- liftIO $ createPool cfg defaultPoolConfig+      outcome <- liftIO $ withReadOnlyTransaction' pool "RETURN 1" H.empty+      liftIO $ destroyPool pool+      case outcome of+        Left _ -> pure ()  -- expected: pool/connect error captured+        Right res -> expectationFailure $+          "expected Left from broken pool, got " <> T.unpack (describeReadOutcome res)++    it "confirm=0 commits a no-op MATCH that affects nothing" $ do+      cleanup+      -- Boundary case: a query that matches no rows reports affected=0.+      -- confirm=0 must commit cleanly rather than rolling back as a mismatch.+      outcome <- liftIO $ wp $ \conn ->+        withConfirmedWriteTransaction conn+          ("MATCH (n:" <> lbl <> "Nonexistent) DETACH DELETE n") H.empty 0+      case outcome of+        Right (WriteCommitted _ _) -> pure ()+        Right other                -> expectationFailure $+          "expected WriteCommitted, got " <> T.unpack (describeWriteOutcome other)+        Left e                     -> expectationFailure $ show e+++-- | Either path that signals "this read tx didn't commit any writes":+-- the post-hoc stats gate (ReadAbortedByWrite) or the server's upfront+-- mode='r' enforcement (Left _).+assertReadDidNotCommit :: Either SomeException ReadOutcome -> ExampleT context IO ()+assertReadDidNotCommit outcome = case outcome of+  Right (ReadAbortedByWrite _) -> pure ()+  Left _                       -> pure ()+  Right other                  -> expectationFailure $+    "expected ReadAbortedByWrite or Left _, got " <> T.unpack (describeReadOutcome other)+++-- ================================================================ -- Server hints integration tests -- ================================================================ @@ -1704,6 +1898,8 @@   notificationTestSuite   -- Query stats tests (Neo4j 5)   statsTestSuite+  -- Gated transactions (Neo4j 5; covers the rollback semantics)+  adminTestSuite   -- Server hints tests (Neo4j 5)   serverHintTestSuite   -- Decode tests (Neo4j 5)
src/Database/Bolty.hs view
@@ -43,8 +43,12 @@   , sendTelemetry     -- * Transactions   , withTransaction-  , withRetryTransaction   , withTransaction'+  , withReadTransaction+  , withReadTransaction'+  , withTxMode+  , withRetryTransaction+  , withRetryTransactionMode     -- * Retry configuration   , RetryConfig(..)   , defaultRetryConfig@@ -102,6 +106,7 @@   , ProfileNode(..)   , queryExplain   , queryProfile+  , renderPlan     -- * Query logging   , QueryLog(..)     -- * Notifications@@ -110,6 +115,7 @@   , Position(..)     -- * Query statistics   , QueryStats(..)+  , formatStatsLine     -- * BoltM monad   , BoltM   , runBolt@@ -149,13 +155,30 @@   , groupByField     -- * PackStream re-exports   , Ps(..)+    -- * Aeson interop+  , parsePs+  , psToAeson+  , propsToAeson+  , propsFromAeson+    -- * Admin / sandbox helpers (Database.Bolty.Admin)+  , ReadOutcome(..)+  , WriteOutcome(..)+  , ReadModeViolation(..)+  , WriteCountMismatch(..)+  , describeReadOutcome+  , describeWriteOutcome+  , withReadOnlyTransaction+  , withReadOnlyTransaction'+  , withConfirmedWriteTransaction+  , withConfirmedWriteTransaction'+  , totalAffected   ) where -import           Database.Bolty.Connection          (queryPWithFieldsIO, queryPMetaIO)+import           Database.Bolty.Connection          (queryPWithFieldsIO, queryPMetaIO, withTxMode) import           Database.Bolty.Logging            (QueryLog(..))-import           Database.Bolty.Plan              (PlanNode(..), ProfileNode(..))+import           Database.Bolty.Plan              (PlanNode(..), ProfileNode(..), renderPlan) import           Database.Bolty.Notification      (Notification(..), Severity(..), Position(..))-import           Database.Bolty.Stats             (QueryStats(..))+import           Database.Bolty.Stats             (QueryStats(..), formatStatsLine) import           Database.Bolty.Decode import           Database.Bolty.Connection.Config   (validateConfig) import qualified Database.Bolty.Connection.Pipe     as P@@ -168,7 +191,8 @@ import           Database.Bolty.Pool import           Database.Bolty.Record import           Database.Bolty.ResultSet-import           Database.Bolty.Routing (AccessMode(..), RoutingPool, RoutingPoolConfig(..),+import           Database.Bolty.AccessMode          (AccessMode(..))+import           Database.Bolty.Routing (RoutingPool, RoutingPoolConfig(..),                                         defaultRoutingPoolConfig, createRoutingPool,                                         destroyRoutingPool, withRoutingConnection,                                         acquireRoutingConnection,@@ -179,7 +203,17 @@                                         readTransaction, writeTransaction, getLastBookmarks) import           Database.Bolty.Value.Type          (Bolt(..), asNull, asBool, asInt, asFloat                                                     , asBytes, asText, asList, asDict-                                                    , asNode, asRelationship, asPath)+                                                    , asNode, asRelationship, asPath+                                                    , parsePs, psToAeson+                                                    , propsToAeson, propsFromAeson)+import           Database.Bolty.Admin               (ReadOutcome(..), WriteOutcome(..)+                                                    , ReadModeViolation(..)+                                                    , WriteCountMismatch(..)+                                                    , describeReadOutcome, describeWriteOutcome+                                                    , withReadOnlyTransaction, withReadOnlyTransaction'+                                                    , withConfirmedWriteTransaction+                                                    , withConfirmedWriteTransaction'+                                                    , totalAffected) import           Database.Bolty.Connection.Version  (Version(..))  import           Data.Kind                           (Type)@@ -320,28 +354,53 @@     Nothing   -> error "queryProfile: no profile in response"  --- | Run an action inside an explicit transaction. Automatically commits on success--- and rolls back on failure.+-- | Run an action inside an explicit /write/ transaction. Automatically+-- commits on success and rolls back on failure.+--+-- Equivalent to @'withTxMode' 'WriteAccess'@. Use 'withReadTransaction'+-- for a read-only transaction that can be cluster-routed to a follower+-- and has the server enforce no writes. withTransaction :: HasCallStack => Connection -> (Connection -> IO a) -> IO a-withTransaction conn action = do-  P.beginTx conn defaultBegin-  result <- action conn `onException` P.tryRollback conn-  _ <- P.commitTx conn-  pure result-  where-    defaultBegin = Begin V.empty Nothing H.empty 'w' Nothing Nothing+withTransaction = withTxMode WriteAccess  +-- | Run an action inside an explicit /read/ transaction. Automatically+-- commits on success and rolls back on failure.+--+-- The transaction is opened with 'ReadAccess': in cluster setups the+-- server routes to a follower, and any direct write attempt is rejected+-- upfront with an @AccessMode@ error (a Neo4j 5+ feature). For application+-- code this is the right pair to 'withTransaction'.+--+-- For an admin-tool variant that additionally inspects 'parsedStats'+-- post-hoc and reports a rollback as a typed 'ReadOutcome' instead of+-- a thrown exception, see 'Database.Bolty.Admin.withReadOnlyTransaction'.+--+-- /Not the same as/ 'Database.Bolty.Session.readTransaction', which runs+-- a multi-step action against a session with bookmarks.+withReadTransaction :: HasCallStack => Connection -> (Connection -> IO a) -> IO a+withReadTransaction = withTxMode ReadAccess++ -- | Run a transaction with automatic retry on transient failures. -- Uses exponential backoff between retries. withRetryTransaction :: HasCallStack                      => RetryConfig -> Connection -> (Connection -> IO a) -> IO a-withRetryTransaction RetryConfig{maxRetries, initialDelay, maxDelay} conn action =+withRetryTransaction = withRetryTransactionMode WriteAccess+++-- | Like 'withRetryTransaction' but parameterised on the 'AccessMode',+-- so the same retry machinery serves both 'withTransaction'' and+-- 'withReadTransaction''.+withRetryTransactionMode+  :: HasCallStack+  => AccessMode -> RetryConfig -> Connection -> (Connection -> IO a) -> IO a+withRetryTransactionMode mode RetryConfig{maxRetries, initialDelay, maxDelay} conn action =     go maxRetries initialDelay   where-    go 0 _     = withTransaction conn action+    go 0 _     = withTxMode mode conn action     go n delay = do-      result <- try $ withTransaction conn action+      result <- try $ withTxMode mode conn action       case result of         Right x -> pure x         Left (e :: SomeException) -> case fromException e :: Maybe Error of@@ -351,10 +410,19 @@           _ -> throwIO e  --- | Convenience: acquire a pooled connection, run a retrying transaction, and release.+-- | Convenience: acquire a pooled connection, run a retrying write+-- transaction, and release. withTransaction' :: HasCallStack => BoltPool -> (Connection -> IO a) -> IO a withTransaction' pool@BoltPool{bpRetryConfig} action =   withConnection pool $ \conn ->-    withRetryTransaction bpRetryConfig conn action+    withRetryTransactionMode WriteAccess bpRetryConfig conn action+++-- | Convenience: acquire a pooled connection, run a retrying read+-- transaction, and release.+withReadTransaction' :: HasCallStack => BoltPool -> (Connection -> IO a) -> IO a+withReadTransaction' pool@BoltPool{bpRetryConfig} action =+  withConnection pool $ \conn ->+    withRetryTransactionMode ReadAccess bpRetryConfig conn action  
+ src/Database/Bolty/AccessMode.hs view
@@ -0,0 +1,41 @@+-- | Access mode for Bolt transactions and routing.+--+-- The Bolt protocol carries a @mode@ field on BEGIN and RUN messages whose+-- only valid values are @"r"@ (read) and @"w"@ (write). This module hides+-- that wire shape behind a sum type so consumers can't construct invalid+-- modes, and so the read\/write distinction reads as data at use sites+-- (cluster routing, transaction primitives, session management).+--+-- The 'PackStream' instance is the single point where the wire encoding+-- lives: 'ReadAccess' \<-\> @"r"@, 'WriteAccess' \<-\> @"w"@.+module Database.Bolty.AccessMode+  ( AccessMode(..)+  ) where++import           Data.Kind            (Type)+import           Data.PackStream      (PackStream(..), Ps(..), Result(..), withString)+import           GHC.Generics         (Generic)+++-- | Whether a transaction (or session connection) is intended for reading+-- or writing. The value flows to two distinct layers:+--+-- * Cluster routing (Session\/Routing layer): 'ReadAccess' picks a follower,+--   'WriteAccess' picks the leader. No-op on non-clustered connections.+-- * Per-transaction enforcement (BEGIN\/RUN @mode@): 'ReadAccess' instructs+--   the server to reject write attempts upfront (Neo4j 5+); 'WriteAccess'+--   is the default and allows writes.+type AccessMode :: Type+data AccessMode = ReadAccess | WriteAccess+  deriving stock (Show, Eq, Ord, Enum, Bounded, Generic)+++-- | Wire format: a single-character PackStream string, @"r"@ or @"w"@.+-- @fromPs@ rejects any other string to preserve the invariant on decode.+instance PackStream AccessMode where+  toPs ReadAccess  = PsString "r"+  toPs WriteAccess = PsString "w"+  fromPs = withString "AccessMode" $ \t -> case t of+    "r" -> Success ReadAccess+    "w" -> Success WriteAccess+    _   -> Error ("expected mode \"r\" or \"w\", got: " <> t)
+ src/Database/Bolty/Admin.hs view
@@ -0,0 +1,312 @@+-- | Helpers for admin tools, migration scripts, and sandbox executors --+-- not intended for application code.+--+-- The functions here trade flexibility for safety: they wrap a hand-rolled+-- BEGIN\/COMMIT\/ROLLBACK with a server-stat-based gate so the caller can+-- be sure a query either stays within an expected effect or doesn't commit+-- at all.+--+-- * 'withReadOnlyTransaction' rolls back if the query produced any updates.+-- * 'withConfirmedWriteTransaction' rolls back unless the actual affected+--   count exactly matches the caller-supplied expectation.+--+-- Both come in two variants: the @Connection@ form takes a connection you+-- already hold, and the primed @'@ form pulls one from a 'BoltPool' for+-- the duration of the call.+--+-- Application code generally knows whether its queries read or write and+-- doesn't need either gate. Use 'Database.Bolty.withTransaction' or the+-- session-level 'Database.Bolty.Session.readTransaction' \/ 'writeTransaction'+-- instead.+--+-- This module lives alongside (rather than inside) 'Database.Bolty' to+-- avoid an import cycle: 'Database.Bolty' re-exports the names below.+-- Internals shared with 'Database.Bolty' (the BEGIN/COMMIT/ROLLBACK+-- wrapper) live in 'Database.Bolty.Connection.withTxMode', re-exported+-- from 'Database.Bolty'.+module Database.Bolty.Admin+  ( -- * Outcomes+    ReadOutcome(..)+  , WriteOutcome(..)+  , describeReadOutcome+  , describeWriteOutcome+    -- * Internal exceptions (escape hatches)+  , ReadModeViolation(..)+  , WriteCountMismatch(..)+    -- * Read-only gated transactions+  , withReadOnlyTransaction+  , withReadOnlyTransaction'+    -- * Confirm-gated write transactions+  , withConfirmedWriteTransaction+  , withConfirmedWriteTransaction'+    -- * Affected-count helper used by the gate+  , totalAffected+  ) where++import           Prelude++import           Control.Exception                  (Exception, SomeException, fromException,+                                                     throwIO, try)+import           Data.Int                           (Int64)+import           Data.Kind                          (Type)+import qualified Data.HashMap.Lazy                  as H+import qualified Data.Text                          as T++import           Data.PackStream.Ps                 (Ps)+import           Database.Bolty.AccessMode          (AccessMode(..))+import           Database.Bolty.Connection          (queryPMetaIO, withTxMode)+import           Database.Bolty.Connection.Type     (Connection)+import           Database.Bolty.Message.Response    (QueryMeta(..))+import           Database.Bolty.Pool                (BoltPool, withConnection)+import           Database.Bolty.ResultSet           (ResultSet(..))+import           Database.Bolty.Stats               (QueryStats(..), formatStatsLine)+++-- =====================================================================+-- Outcomes+-- =====================================================================++-- | Outcome of a 'withReadOnlyTransaction' run that didn't fail with a+-- non-validation error. 'ReadAbortedByWrite' fires when the query produced+-- any updates, which forces the transaction to roll back.+--+-- No 'Show' instance: 'QueryMeta' has one only via a stock derive in+-- "Database.Bolty.Message.Response", and you generally don't want a+-- multi-kilobyte stat dump in your logs. Use 'describeReadOutcome' for+-- a one-liner.+type ReadOutcome :: Type+data ReadOutcome+  = ReadOK !ResultSet !QueryMeta+  | ReadAbortedByWrite !QueryStats+++-- | One-line description of a 'ReadOutcome' suitable for assertion+-- messages and logging.+describeReadOutcome :: ReadOutcome -> T.Text+describeReadOutcome (ReadOK _ _)         = "ReadOK"+describeReadOutcome (ReadAbortedByWrite s) =+  "ReadAbortedByWrite (" <> formatStatsLine s <> ")"+++-- | Outcome of a 'withConfirmedWriteTransaction' run. 'WriteAbortedMismatch'+-- fires when the actual affected count doesn't match the caller-supplied+-- confirm number; the transaction is rolled back in that case.+type WriteOutcome :: Type+data WriteOutcome+  = WriteCommitted !ResultSet !QueryMeta+  | WriteAbortedMismatch !Int64 !Int64 !(Maybe QueryStats)+    -- ^ expected, actual, stats (if any)+++-- | One-line description of a 'WriteOutcome' suitable for assertion+-- messages and logging.+describeWriteOutcome :: WriteOutcome -> T.Text+describeWriteOutcome (WriteCommitted _ _) = "WriteCommitted"+describeWriteOutcome (WriteAbortedMismatch ex actual mStats) =+  "WriteAbortedMismatch expected=" <> T.pack (show ex)+  <> " actual=" <> T.pack (show actual)+  <> maybe "" (\s -> " (" <> formatStatsLine s <> ")") mStats+++-- =====================================================================+-- Internal-but-exported rollback exceptions+-- =====================================================================++-- | Thrown inside 'withReadOnlyTransaction' to trigger a rollback. Caught+-- and converted to 'ReadAbortedByWrite' before returning, so callers+-- normally don't see it. Exported only as an escape hatch for code that+-- builds its own wrappers around the primitive.+type ReadModeViolation :: Type+data ReadModeViolation = ReadModeViolation !QueryStats+  deriving stock (Show)+instance Exception ReadModeViolation+++-- | Thrown inside 'withConfirmedWriteTransaction' to trigger a rollback.+-- Same exported-as-escape-hatch caveat as 'ReadModeViolation'.+type WriteCountMismatch :: Type+data WriteCountMismatch = WriteCountMismatch !Int64 !Int64 !(Maybe QueryStats)+  deriving stock (Show)+instance Exception WriteCountMismatch+++-- =====================================================================+-- Read-only query execution (gated)+-- =====================================================================++-- | Run a single query in an explicit read transaction on the given+-- connection. The transaction is opened with @mode='r'@, which has two+-- effects:+--+-- 1. In cluster setups, the server routes the query to a follower.+-- 2. The server enforces read-only access: any direct write attempt+--    (e.g. @CREATE@, @MATCH ... DELETE@) is rejected with+--    @AccessMode@ before it executes, surfacing as 'Left' from this+--    function with a 'ResponseErrorFailure' inside.+--+-- The function additionally inspects 'parsedStats' post-hoc. If a+-- query slips past the server's mode enforcement and reports+-- 'containsUpdates' (some procedures may bypass enforcement), the+-- transaction is rolled back and the function returns+-- 'ReadAbortedByWrite'. This belt-and-braces gate covers older Neo4j+-- versions and procedure-side mutations that don't trigger+-- @AccessMode@.+--+-- Either way, no committed writes survive. Callers that want to verify+-- "the database is unchanged" should rely on 'Right' = ('ReadOK' or+-- 'ReadAbortedByWrite') and 'Left' both being acceptable; only an+-- accidentally committed write would be a bug.+--+-- This is /not/ the same as 'Database.Bolty.Session.readTransaction',+-- which runs a multi-step action against a session in read mode with+-- bookmarks. 'withReadOnlyTransaction' runs a single query, gates on its+-- stats, and returns the outcome; it has no notion of session state.+--+-- Caveats:+--+-- * Procedures with external side effects (e.g. @apoc.export.csv@)+--   cannot be undone -- their files are written before either gate+--   fires. Use 'withReadOnlyTransaction' for pure Cypher only.+-- * 'containsSystemUpdates' (CREATE DATABASE etc.) is not gated; in+--   practice Neo4j refuses to mix system and data ops in one tx, so+--   this is rarely observable.+withReadOnlyTransaction+  :: Connection+  -> T.Text+  -> H.HashMap T.Text Ps+  -> IO (Either SomeException ReadOutcome)+withReadOnlyTransaction conn query params = do+  result <- try $ withTxMode ReadAccess conn $ \tConn -> do+    (rs, meta) <- runQuery tConn query params+    case parsedStats meta of+      Just s | containsUpdates s -> throwIO (ReadModeViolation s)+      _                          -> pure (rs, meta)+  case result of+    Right (rs, meta) -> pure $ Right (ReadOK rs meta)+    Left e -> case fromException e of+      Just (ReadModeViolation s) -> pure $ Right (ReadAbortedByWrite s)+      Nothing                    -> pure $ Left e+++-- | Pool variant of 'withReadOnlyTransaction'. Acquires a connection from+-- the pool for the duration of the call. Pool-level failures (acquire+-- timeout, validation failure) are captured in the returned 'Either'+-- rather than thrown, matching the contract of 'withReadOnlyTransaction'.+withReadOnlyTransaction'+  :: BoltPool+  -> T.Text+  -> H.HashMap T.Text Ps+  -> IO (Either SomeException ReadOutcome)+withReadOnlyTransaction' pool query params = do+  result <- try $ withConnection pool $ \conn ->+    withReadOnlyTransaction conn query params+  pure $ case result of+    Right inner -> inner       -- already Either+    Left  e     -> Left e      -- pool error+++-- =====================================================================+-- Confirm-gated write transactions+-- =====================================================================++-- | Run a query in an explicit write transaction on the given connection,+-- gated by an expected affected-entity count (see 'totalAffected' for+-- what counts as "affected"). The transaction commits only when the+-- actual count equals @expected@; otherwise it rolls back and the+-- function returns 'WriteAbortedMismatch' with the actual count for the+-- caller to react to.+--+-- The intended use is ad-hoc administrative writes where the operator+-- wants a deliberate \"I know exactly what this query will affect\"+-- handshake before any change becomes durable. Run once with a+-- deliberately wrong expected count to discover the actual count, then+-- re-run with the correct value to commit.+withConfirmedWriteTransaction+  :: Connection+  -> T.Text+  -> H.HashMap T.Text Ps+  -> Int64       -- ^ expected total affected count+  -> IO (Either SomeException WriteOutcome)+withConfirmedWriteTransaction conn query params expected = do+  result <- try $ withTxMode WriteAccess conn $ \tConn -> do+    (rs, meta) <- runQuery tConn query params+    let stats  = parsedStats meta+        actual = totalAffected stats+    if actual /= expected+      then throwIO (WriteCountMismatch expected actual stats)+      else pure (rs, meta)+  case result of+    Right (rs, meta) -> pure $ Right (WriteCommitted rs meta)+    Left e -> case fromException e of+      Just (WriteCountMismatch ex ac st) ->+        pure $ Right (WriteAbortedMismatch ex ac st)+      Nothing -> pure $ Left e+++-- | Pool variant of 'withConfirmedWriteTransaction'. Acquires a+-- connection from the pool for the duration of the call. Pool-level+-- failures are captured in the returned 'Either' rather than thrown,+-- matching the contract of 'withConfirmedWriteTransaction'.+withConfirmedWriteTransaction'+  :: BoltPool+  -> T.Text+  -> H.HashMap T.Text Ps+  -> Int64+  -> IO (Either SomeException WriteOutcome)+withConfirmedWriteTransaction' pool query params expected = do+  result <- try $ withConnection pool $ \conn ->+    withConfirmedWriteTransaction conn query params expected+  pure $ case result of+    Right inner -> inner+    Left  e     -> Left e+++-- =====================================================================+-- Affected-count helper+-- =====================================================================++-- | Sum every mutation counter on a 'QueryStats' record. Used by+-- 'withConfirmedWriteTransaction' to compare actual versus expected+-- effect; lives here rather than in "Database.Bolty.Stats" because the+-- summing semantics are admin-tool-shaped (most application code cares+-- about specific counters, or uses the boolean 'containsUpdates' flag).+--+-- The total includes both data and schema mutations within the current+-- database -- nodes, relationships, properties, labels, indexes, and+-- constraints, both created and removed. It does not net out: a query+-- that creates 5 nodes and deletes 5 nodes reports 10, not 0.+--+-- The asymmetry between @CREATE (:X {a:1})@ (3: node + property + label)+-- and @DETACH DELETE n@ (1: node) is intentional: the counters reflect+-- the work the server performed, not just the headline operation.+--+-- Excluded from the sum:+--+-- * The 'containsUpdates' / 'containsSystemUpdates' boolean flags,+--   which are derived from the counters.+-- * 'systemUpdates' -- a separate counter for system-level changes+--   (CREATE DATABASE, CREATE USER, etc.) that affect server-wide state+--   rather than the current database.+totalAffected :: Maybe QueryStats -> Int64+totalAffected Nothing  = 0+totalAffected (Just s) =+    nodesCreated s + nodesDeleted s+  + relationshipsCreated s + relationshipsDeleted s+  + propertiesSet s+  + labelsAdded s + labelsRemoved s+  + indexesAdded s + indexesRemoved s+  + constraintsAdded s + constraintsRemoved s+++-- =====================================================================+-- Internals+-- =====================================================================++-- Run a query and bundle the result into a 'ResultSet'. Mirrors+-- 'Database.Bolty.queryResultMeta' but at the IO level so we don't+-- depend on the 'BoltM' newtype (which would cycle through+-- 'Database.Bolty').+runQuery :: Connection -> T.Text -> H.HashMap T.Text Ps -> IO (ResultSet, QueryMeta)+runQuery conn query params = do+  (fs, rs, meta) <- queryPMetaIO conn query params+  pure (ResultSet fs rs, meta)
src/Database/Bolty/Connection.hs view
@@ -9,16 +9,19 @@   , queryPMetaIO   , requestResponseRunIO   , requestResponsePullIO+  , withTxMode   ) where  import Prelude +import           Database.Bolty.AccessMode        (AccessMode) import qualified Database.Bolty.Connection.Pipe as P import           Database.Bolty.Connection.Type import           Database.Bolty.Logging+import           Database.Bolty.Message.Request   (Begin(Begin)) import           Database.Bolty.Record -import           Control.Exception             (SomeException, throwIO, try)+import           Control.Exception             (SomeException, onException, throwIO, try) import           Data.IORef                    (IORef, newIORef, readIORef, writeIORef) import           Data.Kind                     (Type) import           Data.Text                     (Text)@@ -297,3 +300,20 @@   case notificationHandler of     Nothing      -> pure ()     Just handler -> V.mapM_ handler parsedNotifications+++-- | Run an action inside an explicit transaction opened with the given+-- 'AccessMode'. Automatically commits on success and attempts a rollback+-- on failure.+--+-- The mode controls cluster routing: 'ReadAccess' routes to a follower+-- and engages the server's read-only enforcement; 'WriteAccess' routes+-- to the leader. Higher-level helpers in "Database.Bolty"+-- ('Database.Bolty.withTransaction' / 'Database.Bolty.withReadTransaction')+-- are thin wrappers over this primitive.+withTxMode :: HasCallStack => AccessMode -> Connection -> (Connection -> IO a) -> IO a+withTxMode mode conn action = do+  P.beginTx conn (Begin V.empty Nothing H.empty mode Nothing Nothing)+  result <- action conn `onException` P.tryRollback conn+  _ <- P.commitTx conn+  pure result
src/Database/Bolty/Message/Request.hs view
@@ -16,9 +16,9 @@   , RunExtra(..), defaultRunExtra   ) where -import           Data.HashMap.Lazy              (HashMap) import           Data.Int                      (Int64) import           Data.Kind                     (Type)+import           Data.Maybe                    (fromMaybe) import           GHC.Generics                  (Generic) import qualified Data.HashMap.Lazy             as H import qualified Data.Text                     as T@@ -26,6 +26,7 @@  import           Data.PackStream import           Data.PackStream.Integer       (toPSInteger, fromPSInteger)+import           Database.Bolty.AccessMode     (AccessMode(..)) import           Database.Bolty.Connection.Instances () import           Database.Bolty.Connection.Type (UserAgent(..), Scheme(..), Routing(..)) @@ -96,7 +97,7 @@                   bookmarks   :: V.Vector T.Text     <- lookupWithError "bookmarks" map "\"bookmarks\" not found in Run Request"                   tx_timeout  :: Maybe Int64         <- lookupMaybe "tx_timeout" map                   tx_metadata :: H.HashMap T.Text Ps <- lookupWithError "tx_metadata" map "\"tx_metadata\" not found in Run Request"-                  mode        :: Char                <- fromPsMode map+                  mode        :: AccessMode          <- lookupMode map                   db          :: Maybe T.Text        <- lookupMaybe "db" map                   imp_user    :: Maybe T.Text        <- lookupMaybe "imp_user" map                   pure $ RRunAutoCommitTransaction RunAutoCommitTransaction{query, parameters, extra, bookmarks, tx_timeout, tx_metadata, mode, db, imp_user}@@ -150,7 +151,7 @@   , bookmarks   = V.empty   , tx_timeout  = Nothing   , tx_metadata = H.empty-  , mode        = 'w'+  , mode        = WriteAccess   , db          = Nothing   , imp_user    = Nothing   }@@ -164,7 +165,7 @@   , bookmarks   = V.empty   , tx_timeout  = Nothing   , tx_metadata = H.empty-  , mode        = 'w'+  , mode        = WriteAccess   , db          = Nothing   , imp_user    = Nothing   }@@ -178,7 +179,7 @@   , bookmarks   :: V.Vector T.Text   , tx_timeout  :: !(Maybe Int64)   , tx_metadata :: !(H.HashMap T.Text Ps)-  , mode        :: !Char+  , mode        :: !AccessMode   , db          :: !(Maybe T.Text)   , imp_user    :: !(Maybe T.Text)   }@@ -198,7 +199,7 @@                               Just i  -> [("imp_user", toPs i)]         in  [ ("bookmarks", toPs bookmarks)             , ("tx_metadata", toPs tx_metadata)-            , ("mode", toPs $ T.singleton mode)+            , ("mode", toPs mode)             , ("db", toPs db)             ] <> tx_timeout_list <> imp_user_list     ]@@ -226,7 +227,7 @@   { bookmarks   :: !(V.Vector T.Text)   , tx_timeout  :: !(Maybe Int64)   , tx_metadata :: !(H.HashMap T.Text Ps)-  , mode        :: !Char+  , mode        :: !AccessMode   , db          :: !(Maybe T.Text)   , imp_user    :: !(Maybe T.Text)   }@@ -236,7 +237,7 @@     [ ("bookmarks", toPs bookmarks)     , ("tx_timeout", toPs tx_timeout)     , ("tx_metadata", toPs tx_metadata)-    , ("mode", toPs $ T.singleton mode)+    , ("mode", toPs mode)     , ("db", toPs db)     , ("imp_user", toPs imp_user)     ]@@ -246,7 +247,7 @@             bookmarks :: V.Vector T.Text <- lookupWithError "bookmarks" map "\"bookmarks\" not found in Begin"             tx_timeout :: Maybe Int64 <- lookupMaybe "tx_timeout" map             tx_metadata :: H.HashMap T.Text Ps <- lookupWithError "tx_metadata" map "\"tx_metadata\" not found in Begin"-            mode :: Char <- fromPsMode map+            mode :: AccessMode <- lookupMode map             db :: (Maybe T.Text) <- lookupMaybe "db" map             imp_user :: Maybe T.Text <- lookupMaybe "imp_user" map             pure $ Begin{bookmarks, tx_timeout, tx_metadata, mode, db, imp_user}@@ -326,7 +327,7 @@   { bookmarks   :: !(V.Vector T.Text)   , tx_timeout  :: !(Maybe Int64)   , tx_metadata :: !(H.HashMap T.Text Ps)-  , mode        :: !Char+  , mode        :: !AccessMode   , db          :: !(Maybe T.Text)   , imp_user    :: !(Maybe T.Text)   }@@ -337,7 +338,7 @@   { bookmarks   = V.empty   , tx_timeout  = Nothing   , tx_metadata = H.empty-  , mode        = 'w'+  , mode        = WriteAccess   , db          = Nothing   , imp_user    = Nothing   }@@ -347,7 +348,7 @@     [ ("bookmarks", toPs bookmarks)     , ("tx_timeout", toPs tx_timeout)     , ("tx_metadata", toPs tx_metadata)-    , ("mode", toPs $ T.singleton mode)+    , ("mode", toPs mode)     , ("db", toPs db)     , ("imp_user", toPs imp_user)     ]@@ -357,21 +358,12 @@             bookmarks :: V.Vector T.Text <- lookupWithError "bookmarks" map "\"bookmarks\" not found in RunExtra"             tx_timeout :: Maybe Int64 <- lookupMaybe "tx_timeout" map             tx_metadata :: H.HashMap T.Text Ps <- lookupWithError "tx_metadata" map "\"tx_metadata\" not found in RunExtra"-            mode :: Char <- fromPsMode map+            mode :: AccessMode <- lookupMode map             db :: Maybe T.Text <- lookupMaybe "db" map             imp_user :: Maybe T.Text <- lookupMaybe "imp_user" map             pure $ RunExtra{bookmarks, tx_timeout, tx_metadata, mode, db, imp_user}  -fromPsMode :: HashMap T.Text Ps -> Result Char-fromPsMode map = do-  mode :: T.Text <- lookupWithError "mode" map "\"mode\" not found in RunExtra"-  if T.length mode == 1 then-    Success $ T.head mode-  else-    Error "\"mode\" in RunExtra should be a single character of either 'r' or 'w'"-- helloToPs :: Hello -> Ps helloToPs Hello{user_agent, scheme, routing, patchBolt} =     PsDictionary $ H.fromList $@@ -524,3 +516,11 @@         Just i  -> fmap RTelemetry $ telemetryApiFromInt i         Nothing -> Error "telemetry api integer out of Int64 range"       _           -> Error "expected integer field for Telemetry"+++-- | Look up the BEGIN\/RUN @mode@ field, defaulting to 'WriteAccess' when+-- absent. Per the Bolt protocol, @mode@ is an optional field with default+-- @"w"@; spec-compliant peers may omit it on write transactions to save+-- bytes. A bare @lookupWithError@ would reject those messages.+lookupMode :: H.HashMap T.Text Ps -> Result AccessMode+lookupMode m = fromMaybe WriteAccess <$> lookupMaybe "mode" m
src/Database/Bolty/Message/Response.hs view
@@ -227,6 +227,7 @@   , db :: !T.Text   -- ^ the database name where the query was executed (v4.0+).   }+  deriving stock (Show)  -- | Parsed COMMIT SUCCESS metadata. type SuccessCommit :: Type
src/Database/Bolty/Plan.hs view
@@ -4,6 +4,7 @@   , ProfileNode(..)   , parsePlan   , parseProfile+  , renderPlan   ) where  import           Prelude@@ -152,3 +153,35 @@     Just i  -> i     Nothing -> 0   _ -> 0+++-- | Render a 'PlanNode' tree as an indented text block. Each child is+-- indented two spaces deeper than its parent. Identifier vectors (the+-- @vars:@ line) are shown when non-empty so a reader can match+-- variables to operators.+--+-- Operator arguments ('pnArguments') are intentionally dropped for+-- terseness -- the operator name + estimated rows + identifiers are+-- enough to read most plans, and the args dictionary is verbose.+--+-- Pass @0@ for the initial @depth@. Suitable for printing the result+-- of 'queryExplain'.+--+-- Example output:+--+-- > + ProduceResults  (~1.0 rows)+-- >     vars: u, name+-- >   + Projection  (~1.0 rows)+-- >       vars: u, name+-- >     + NodeUniqueIndexSeek  (~1.0 rows)+-- >         vars: u+renderPlan :: Int -> PlanNode -> T.Text+renderPlan depth (PlanNode op _args idents est children) =+  let indent = T.replicate depth "  "+      header = indent <> "+ " <> op+             <> "  (~" <> T.pack (show est) <> " rows)"+      idLine = if V.null idents+                 then ""+                 else indent <> "    vars: " <> T.intercalate ", " (V.toList idents) <> "\n"+      kids   = T.concat $ V.toList $ V.map (renderPlan (depth + 1)) children+  in header <> "\n" <> idLine <> kids
src/Database/Bolty/Routing.hs view
@@ -3,8 +3,6 @@   ( -- * Routing table     getRoutingTable   , RoutingTable(..)-    -- * Access mode-  , AccessMode(..)     -- * Routing pool   , RoutingPool(..)   , RoutingPoolConfig(..)@@ -36,6 +34,7 @@ import           GHC.Clock                      (getMonotonicTimeNSec) import           GHC.Stack                      (HasCallStack) +import           Database.Bolty.AccessMode      (AccessMode(..)) import           Database.Bolty.Connection.Type import qualified Database.Bolty.Connection.Pipe as P import           Database.Bolty.Message.Request (Request(..), Route(..), RouteExtra(..), Begin(..))@@ -43,12 +42,6 @@ import           Database.Bolty.Pool  --- | Access mode for routing: determines whether to use reader or writer servers.-type AccessMode :: Type-data AccessMode = ReadAccess | WriteAccess-  deriving stock (Show, Eq)-- -- | Fetch a routing table from the server. The connection must be in Ready state. getRoutingTable :: HasCallStack => Connection -> Maybe Text -> IO RoutingTable getRoutingTable conn dbName = do@@ -258,10 +251,6 @@                 go (n - 1) (min maxD (delay * 2)) maxD           _ -> throwIO e -    modeChar = case mode of-      ReadAccess  -> 'r'-      WriteAccess -> 'w'-     attempt = do       rt <- getOrRefreshTable rp       let addrs = case mode of@@ -272,7 +261,7 @@       addr <- roundRobin rp addrs       pool <- getOrCreatePool rp addr       withConnection pool $ \conn -> do-        P.beginTx conn $ Begin V.empty Nothing H.empty modeChar Nothing Nothing+        P.beginTx conn $ Begin V.empty Nothing H.empty mode Nothing Nothing         result <- action conn `onException` P.tryRollback conn         _ <- P.commitTx conn         pure result
src/Database/Bolty/Session.hs view
@@ -30,11 +30,12 @@ import qualified Data.Vector                    as V import           GHC.Stack                      (HasCallStack) +import           Database.Bolty.AccessMode      (AccessMode(..)) import           Database.Bolty.Connection.Type import qualified Database.Bolty.Connection.Pipe as P import           Database.Bolty.Message.Request (Begin(Begin), TelemetryApi(..)) import           Database.Bolty.Pool-import           Database.Bolty.Routing (AccessMode(..), RoutingPool(..), withRoutingConnection,+import           Database.Bolty.Routing (RoutingPool(..), withRoutingConnection,                                         invalidateRoutingTable)  @@ -183,15 +184,11 @@                 go (n - 1) (min maxD (delay * 2)) maxD           _ -> throwIO e -    modeChar = case mode of-      ReadAccess  -> 'r'-      WriteAccess -> 'w'-     attempt =       withSessionConnection sPool mode $ \conn -> do         -- Get current bookmarks and begin the transaction         bms <- getBookmarks sBookmarks-        P.beginTx conn $ Begin (V.fromList bms) Nothing H.empty modeChar (database sConfig) Nothing+        P.beginTx conn $ Begin (V.fromList bms) Nothing H.empty mode (database sConfig) Nothing         -- Run the user's action, rollback on error         result <- action conn `onException` P.tryRollback conn         -- Commit and extract bookmark
src/Database/Bolty/Stats.hs view
@@ -2,6 +2,7 @@ module Database.Bolty.Stats   ( QueryStats(..)   , parseStats+  , formatStatsLine   ) where  import           Prelude@@ -73,3 +74,26 @@ boolField key m = case H.lookup key m of   Just (PsBoolean b) -> b   _                  -> False+++-- | One-line summary of every counter on a 'QueryStats' record. Format:+--+-- > Stats: +nodes=1 -nodes=0 +rels=0 -rels=0 props=1 +labels=1 -labels=0 +idx=0 -idx=0 +constr=0 -constr=0+--+-- Useful for diagnostic logging and CLI rollback messages where you want+-- the whole counter set on one line for terse comparison across runs.+formatStatsLine :: QueryStats -> T.Text+formatStatsLine s = T.concat+  [ "Stats: "+  , "+nodes=", T.pack (show (nodesCreated s))+  , " -nodes=", T.pack (show (nodesDeleted s))+  , " +rels=", T.pack (show (relationshipsCreated s))+  , " -rels=", T.pack (show (relationshipsDeleted s))+  , " props=", T.pack (show (propertiesSet s))+  , " +labels=", T.pack (show (labelsAdded s))+  , " -labels=", T.pack (show (labelsRemoved s))+  , " +idx=", T.pack (show (indexesAdded s))+  , " -idx=", T.pack (show (indexesRemoved s))+  , " +constr=", T.pack (show (constraintsAdded s))+  , " -constr=", T.pack (show (constraintsRemoved s))+  ]
src/Database/Bolty/Value/Type.hs view
@@ -1,6 +1,8 @@--- | Internal module. Not part of the public API.+-- | Bolt value types: the Haskell representation of Neo4j values returned by queries. ----- Bolt value types: the Haskell representation of Neo4j values returned by queries.+-- The Aeson interop helpers ('parsePs', 'psToAeson', 'propsToAeson',+-- 'propsFromAeson') are part of the stable public API; everything else+-- is the internal value-type machinery surfaced via 'Database.Bolty'. module Database.Bolty.Value.Type   ( Bolt(..)     -- * Bolt value extractors@@ -22,6 +24,13 @@     -- * Spatial types   , Point2D(..)   , Point3D(..)+    -- * Aeson interop+    --+    -- $aesonInterop+  , parsePs+  , psToAeson+  , propsToAeson+  , propsFromAeson   ) where  import           Control.Applicative    ((<|>), empty)@@ -384,6 +393,17 @@   -- = Aeson (ToJSON / FromJSON) instances+--+-- $aesonInterop+--+-- Helpers for moving values between PackStream's 'Ps' and Aeson's 'Aeson.Value'.+-- Useful when binding query parameters from JSON input (e.g. a CLI) or when+-- rendering query results as JSON.+--+-- 'parsePs' handles the @{"bytes": "<base64>"}@ shape so binary values can+-- round-trip through JSON. Both functions use 'scientificToPs', which falls+-- back to 'PsFloat' when an integer literal exceeds the @[-2^63, 2^64-1]@+-- range supported by 'PSInteger' rather than silently truncating.  -- | Convert a PackStream property value to JSON. psToAeson :: Ps -> Aeson.Value
test/Main.hs view
@@ -9,6 +9,7 @@ import           Data.Word                         (Word32, Word64) import qualified Data.HashMap.Lazy                 as H import qualified Data.Text                         as T+import qualified Data.Text.Encoding                as TE import qualified Data.Vector                       as V import qualified Validation                        as Val @@ -22,16 +23,21 @@ import qualified Database.Bolty.ResultSet        as RS import           Database.Bolty.Logging           (QueryLog(..)) import           Database.Bolty.Notification     (Notification(..), Severity(..), Position(..), parseNotifications)-import           Database.Bolty.Plan            (PlanNode(..), ProfileNode(..), parsePlan, parseProfile)-import           Database.Bolty.Stats           (QueryStats(..), parseStats)+import           Database.Bolty.Plan            (PlanNode(..), ProfileNode(..), parsePlan, parseProfile, renderPlan)+import           Database.Bolty.Stats           (QueryStats(..), parseStats, formatStatsLine)+import           Database.Bolty.Admin           (totalAffected) import           Database.Bolty.Value.Type        (Node(..), Relationship(..), UnboundRelationship(..)                                                   , Path(..), Date(..), Time(..), LocalTime(..)                                                   , DateTime(..), DateTimeZoneId(..), LocalDateTime(..)                                                   , Duration(..), Point2D(..), Point3D(..)) import           Database.Bolty.Message.Request    (Request(..), Hello(..), Route(..), RouteExtra(..)+                                                  , Begin(..), RunExtra(..)+                                                  , RunAutoCommitTransaction(..)                                                   , Logon(..), TelemetryApi(..)                                                   , Pull(..), defaultPull)-import           Database.Bolty.Message.Response   (Response(..), RoutingTable(..), parseRoutingTable, extractBookmark)+import           Database.Bolty.Message.Response   (Response(..), RoutingTable(..), parseRoutingTable, extractBookmark, QueryMeta(..))+import qualified Database.Bolty.Message.Response   as Resp+import qualified Data.Aeson.KeyMap                 as KM import           Database.Bolty.Routing            (parseAddress) import           Database.Bolty.Session            (newBookmarkManager, getBookmarks, updateBookmark) import           Database.Bolty.Decode            (psToBolt, boltToPs)@@ -40,7 +46,7 @@ import           Control.Exception                 (IOException, SomeException, fromException, toException) import qualified Data.Aeson                        as Aeson import           Data.Aeson                        (fromJSON, toJSON)-import qualified Data.Aeson.Types                  as Aeson (Result(..))+import qualified Data.Aeson.Types                  as Aeson (Result(..), parseEither) import qualified Data.ByteString                   as BS import qualified Data.Vector.Mutable               as MV import           Test.QuickCheck                   (Arbitrary(..), Gen, Result, oneof, listOf,@@ -341,6 +347,127 @@       other -> expectationFailure $ "Expected BoltPoint2D, got: " <> show other  +-- = AccessMode tests++accessModeTests :: TopSpec+accessModeTests = describe "AccessMode" $ do+  it "ReadAccess encodes to PsString \"r\"" $+    toPs ReadAccess `shouldBe` PsString "r"++  it "WriteAccess encodes to PsString \"w\"" $+    toPs WriteAccess `shouldBe` PsString "w"++  it "round-trips ReadAccess" $+    (fromPs (toPs ReadAccess) :: R.Result AccessMode) `shouldBe` R.Success ReadAccess++  it "round-trips WriteAccess" $+    (fromPs (toPs WriteAccess) :: R.Result AccessMode) `shouldBe` R.Success WriteAccess++  it "rejects an unknown single-char string" $+    case (fromPs (PsString "x") :: R.Result AccessMode) of+      R.Error _   -> pure ()+      R.Success a -> expectationFailure $ "Expected Error, got: " <> show a++  it "rejects the empty string" $+    case (fromPs (PsString "") :: R.Result AccessMode) of+      R.Error _   -> pure ()+      R.Success a -> expectationFailure $ "Expected Error, got: " <> show a++  it "rejects a non-string Ps" $+    case (fromPs (PsInteger 1) :: R.Result AccessMode) of+      R.Error _   -> pure ()+      R.Success a -> expectationFailure $ "Expected Error, got: " <> show a++  it "enumerates exhaustively via Bounded + Enum" $+    [minBound .. maxBound] `shouldBe` [ReadAccess, WriteAccess]++  -- Per Bolt spec, "mode" in BEGIN/RUN extra is optional with default "w".+  -- A spec-compliant peer may omit it on write transactions.+  it "Begin.fromPs accepts absent \"mode\" key and defaults to WriteAccess" $ do+    -- Mirror a spec-compliant peer's BEGIN extra dict: required keys+    -- present, optional keys (mode, tx_timeout, db, imp_user) omitted.+    let dict = PsDictionary $ H.fromList+          [ ("bookmarks", toPs (V.empty :: V.Vector T.Text))+          , ("tx_metadata", PsDictionary H.empty)+          ]+    case (fromPs dict :: R.Result Begin) of+      R.Success b -> b.mode `shouldBe` WriteAccess+      R.Error e   -> expectationFailure $ "Expected Success, got: " <> T.unpack e++  it "Begin.fromPs accepts explicit \"mode\" = \"r\"" $ do+    let dict = PsDictionary $ H.fromList+          [ ("bookmarks", toPs (V.empty :: V.Vector T.Text))+          , ("tx_metadata", PsDictionary H.empty)+          , ("mode", PsString "r")+          ]+    case (fromPs dict :: R.Result Begin) of+      R.Success b -> b.mode `shouldBe` ReadAccess+      R.Error e   -> expectationFailure $ "Expected Success, got: " <> T.unpack e++  it "RunExtra.fromPs accepts absent \"mode\" key and defaults to WriteAccess" $ do+    let dict = PsDictionary $ H.fromList+          [ ("bookmarks", toPs (V.empty :: V.Vector T.Text))+          , ("tx_metadata", PsDictionary H.empty)+          ]+    case (fromPs dict :: R.Result RunExtra) of+      R.Success r -> (r :: RunExtra).mode `shouldBe` WriteAccess+      R.Error e   -> expectationFailure $ "Expected Success, got: " <> T.unpack e++  it "RunExtra.fromPs accepts explicit \"mode\" = \"r\"" $ do+    let dict = PsDictionary $ H.fromList+          [ ("bookmarks", toPs (V.empty :: V.Vector T.Text))+          , ("tx_metadata", PsDictionary H.empty)+          , ("mode", PsString "r")+          ]+    case (fromPs dict :: R.Result RunExtra) of+      R.Success r -> (r :: RunExtra).mode `shouldBe` ReadAccess+      R.Error e   -> expectationFailure $ "Expected Success, got: " <> T.unpack e++  -- Invalid mode values must surface as an Error from the parent decoder,+  -- not silently fall back to WriteAccess (which is the absent-key default).+  it "Begin.fromPs rejects invalid \"mode\" value" $ do+    let dict = PsDictionary $ H.fromList+          [ ("bookmarks", toPs (V.empty :: V.Vector T.Text))+          , ("tx_metadata", PsDictionary H.empty)+          , ("mode", PsString "x")+          ]+    case (fromPs dict :: R.Result Begin) of+      R.Error _    -> pure ()+      R.Success b  -> expectationFailure $ "Expected Error, got mode=" <> show b.mode++  it "RunExtra.fromPs rejects invalid \"mode\" value" $ do+    let dict = PsDictionary $ H.fromList+          [ ("bookmarks", toPs (V.empty :: V.Vector T.Text))+          , ("tx_metadata", PsDictionary H.empty)+          , ("mode", PsString "X")  -- uppercase: a likely human-typo case+          ]+    case (fromPs dict :: R.Result RunExtra) of+      R.Error _    -> pure ()+      R.Success r  -> expectationFailure $ "Expected Error, got mode=" <> show ((r :: RunExtra).mode)++  -- Third lookupMode callsite: the autocommit RUN decoder (fromPsToRunRequest).+  -- Routes through PsStructure 0x10 + the metadata-keys gate that picks the+  -- autocommit branch over the explicit-tx branch.+  it "Autocommit RUN decoder accepts absent \"mode\" and defaults to WriteAccess" $ do+    let runDict = PsDictionary $ H.fromList+          [ ("query", PsString "RETURN 1")+          , ("parameters", PsDictionary H.empty)+          , ("extra", PsDictionary $ H.fromList+              [ ("bookmarks", toPs (V.empty :: V.Vector T.Text))+              , ("tx_metadata", PsDictionary H.empty)+              ])+          , ("bookmarks", toPs (V.empty :: V.Vector T.Text))  -- forces autocommit branch+          , ("tx_metadata", PsDictionary H.empty)+          ]+    case (fromPs (PsStructure 0x10 (V.singleton runDict)) :: R.Result Request) of+      R.Success (RRunAutoCommitTransaction rac) ->+        (rac :: RunAutoCommitTransaction).mode `shouldBe` WriteAccess+      R.Success _ ->+        expectationFailure "Expected RRunAutoCommitTransaction, got a different Request constructor"+      R.Error e ->+        expectationFailure $ "Expected Success, got: " <> T.unpack e++ -- = Pool config tests  poolConfigTests :: TopSpec@@ -959,7 +1086,59 @@         nodesCreated qs `shouldBe` 0         containsUpdates qs `shouldBe` False +  -- = totalAffected +  it "totalAffected Nothing returns 0" $+    totalAffected Nothing `shouldBe` 0++  it "totalAffected of zeros is 0" $+    totalAffected (Just zeroStats) `shouldBe` 0++  it "totalAffected counts CREATE side effects (1 node + 1 prop + 1 label = 3)" $+    totalAffected (Just zeroStats { nodesCreated = 1, propertiesSet = 1, labelsAdded = 1 })+      `shouldBe` 3++  it "totalAffected counts a DETACH DELETE as 1" $+    totalAffected (Just zeroStats { nodesDeleted = 1 }) `shouldBe` 1++  it "totalAffected sums create + delete (no cancellation)" $+    totalAffected (Just zeroStats { nodesCreated = 5, nodesDeleted = 5 }) `shouldBe` 10++  it "totalAffected counts indexes and constraints" $+    totalAffected (Just zeroStats+      { constraintsAdded = 1, indexesAdded = 2, indexesRemoved = 1 })+      `shouldBe` 4++  it "totalAffected ignores containsUpdates and systemUpdates" $+    -- System updates are tracked separately and shouldn't bleed into the+    -- schema-update tally.+    totalAffected (Just zeroStats { containsUpdates = True, systemUpdates = 99 })+      `shouldBe` 0++  -- = formatStatsLine++  it "formatStatsLine renders every counter for an all-zero stat" $+    formatStatsLine zeroStats `shouldBe`+      "Stats: +nodes=0 -nodes=0 +rels=0 -rels=0 props=0 \+      \+labels=0 -labels=0 +idx=0 -idx=0 +constr=0 -constr=0"++  it "formatStatsLine renders a CREATE-style breakdown" $+    formatStatsLine zeroStats { nodesCreated = 1, propertiesSet = 1, labelsAdded = 1 }+      `shouldBe` "Stats: +nodes=1 -nodes=0 +rels=0 -rels=0 props=1 \+                 \+labels=1 -labels=0 +idx=0 -idx=0 +constr=0 -constr=0"++  it "formatStatsLine renders every field with a non-zero value" $+    let allOnes = QueryStats 1 1 1 1 1 1 1 1 1 1 1 False False 0+    in formatStatsLine allOnes `shouldBe`+         "Stats: +nodes=1 -nodes=1 +rels=1 -rels=1 props=1 \+         \+labels=1 -labels=1 +idx=1 -idx=1 +constr=1 -constr=1"+++-- | Helper used by the totalAffected/formatStatsLine cases above.+zeroStats :: QueryStats+zeroStats = QueryStats 0 0 0 0 0 0 0 0 0 0 0 False False 0++ -- = Plan/Profile parsing tests  planTests :: TopSpec@@ -1041,7 +1220,53 @@       Nothing -> expectationFailure "Expected Just PlanNode"       Just pn -> V.length (pnChildren pn) `shouldBe` 2 +  -- = renderPlan +  it "renderPlan emits operator + estimated rows for a leaf" $+    renderPlan 0 (leafPlan "AllNodesScan" 100.0 [])+      `shouldBe` "+ AllNodesScan  (~100.0 rows)\n"++  it "renderPlan emits a vars: line when identifiers are present" $+    renderPlan 0 (leafPlan "NodeByLabelScan" 5.0 ["u"])+      `shouldBe` "+ NodeByLabelScan  (~5.0 rows)\n    vars: u\n"++  it "renderPlan indents two spaces per depth level" $+    renderPlan 2 (leafPlan "Limit" 1.0 [])+      `shouldBe` "    + Limit  (~1.0 rows)\n"++  it "renderPlan walks a parent->child tree" $+    let child  = leafPlan "Scan" 10.0 ["n"]+        parent = (leafPlan "Projection" 10.0 ["n", "x"])+                   { pnChildren = V.singleton child }+    in renderPlan 0 parent `shouldBe`+         "+ Projection  (~10.0 rows)\n\+         \    vars: n, x\n\+         \  + Scan  (~10.0 rows)\n\+         \      vars: n\n"++  it "renderPlan keeps multiple children in source order" $+    let c1 = leafPlan "L" 1.0 []+        c2 = leafPlan "R" 2.0 []+        parent = (leafPlan "Join" 3.0 [])+                   { pnChildren = V.fromList [c1, c2] }+    in renderPlan 0 parent `shouldBe`+         "+ Join  (~3.0 rows)\n\+         \  + L  (~1.0 rows)\n\+         \  + R  (~2.0 rows)\n"+++-- | Helper: build a childless 'PlanNode' from operator + estimated rows+-- + identifiers. Used by the renderPlan tests.+leafPlan :: T.Text -> Double -> [T.Text] -> PlanNode+leafPlan op est idents = PlanNode+  { pnOperatorType  = op+  , pnArguments     = H.empty+  , pnIdentifiers   = V.fromList idents+  , pnEstimatedRows = est+  , pnChildren      = V.empty+  }++ profileTests :: TopSpec profileTests = describe "Profile parsing" $ do @@ -2218,6 +2443,143 @@         fromJSON (toJSON p) == Aeson.Success p       assertQC r +  describe "parsePs / psToAeson (public Aeson interop)" $ do++    it "parsePs handles every JSON scalar type" $ do+      let parse v = Aeson.parseEither parsePs v+      parse Aeson.Null            `shouldBe` Right PsNull+      parse (Aeson.Bool True)     `shouldBe` Right (PsBoolean True)+      parse (Aeson.String "hi")   `shouldBe` Right (PsString "hi")+      parse (Aeson.Number 42)     `shouldBe` Right (PsInteger (toPSInteger (42 :: Int)))+      parse (Aeson.Number 1.5)    `shouldBe` Right (PsFloat 1.5)++    it "parsePs handles arrays and nested objects" $ do+      let parse v = Aeson.parseEither parsePs v+      parse (Aeson.Array (V.fromList [Aeson.Number 1, Aeson.String "two"])) `shouldBe`+        Right (PsList (V.fromList [PsInteger (toPSInteger (1 :: Int)), PsString "two"]))++    it "parsePs decodes the {bytes: <base64>} shape into PsBytes" $ do+      let v = Aeson.Object (KM.fromList [("bytes", Aeson.String "aGVsbG8=")])  -- "hello"+      case Aeson.parseEither parsePs v of+        Right (PsBytes bs) -> bs `shouldBe` "hello"+        Right other        -> expectationFailure $ "expected PsBytes, got " <> show other+        Left err           -> expectationFailure err++    it "parsePs falls back to dictionary when {bytes: ...} value isn't valid base64" $ do+      let v = Aeson.Object (KM.fromList [("bytes", Aeson.String "not base 64!!!")])+      case Aeson.parseEither parsePs v of+        Right (PsDictionary _) -> pure ()+        other                  -> expectationFailure $ "expected PsDictionary fallback, got " <> show other++    it "psToAeson is the inverse of parsePs for plain values" $ do+      let original = PsList (V.fromList+            [ PsNull+            , PsBoolean True+            , PsString "x"+            , PsInteger (toPSInteger (7 :: Int))+            , PsFloat 1.5+            ])+      let json = psToAeson original+      Aeson.parseEither parsePs json `shouldBe` Right original++    it "parsePs handles negative integers" $ do+      Aeson.parseEither parsePs (Aeson.Number (-7))+        `shouldBe` Right (PsInteger (toPSInteger (-7 :: Int)))++    it "parsePs falls back to dictionary when {bytes: ...} has extra keys" $ do+      -- The bytes shape is a single-key match: {"bytes": "<base64>"}.+      -- Adding any other key turns the value back into a regular dictionary.+      let v = Aeson.Object (KM.fromList+                [ ("bytes", Aeson.String "aGVsbG8=")+                , ("extra", Aeson.String "noise")+                ])+      case Aeson.parseEither parsePs v of+        Right (PsDictionary d) -> H.size d `shouldBe` 2+        other -> expectationFailure $ "expected PsDictionary, got " <> show other++    it "parsePs falls back to PsFloat for integers above PSInteger range" $ do+      -- PSInteger covers [-2^63, 2^64-1]. 2^64 = 18446744073709551616.+      -- Larger than that exceeds the range and must round-trip through PsFloat+      -- rather than silently truncate.+      let huge = "18446744073709551617"  -- 2^64 + 1+      case Aeson.eitherDecodeStrict (TE.encodeUtf8 (T.pack huge)) of+        Left err -> expectationFailure $ "JSON parse failed: " <> err+        Right v  -> case Aeson.parseEither parsePs v of+          Right (PsFloat _) -> pure ()+          Right other       -> expectationFailure $+            "expected PsFloat fallback for huge integer, got " <> show other+          Left err          -> expectationFailure err++  describe "Show QueryMeta (smoke)" $ do++    it "Show QueryMeta produces a non-empty string" $+      -- The data constructor has 13 fields; this just pins that the+      -- standalone Show derive landed and doesn't fail at runtime.+      let m = QueryMeta+            { Resp.bookmark = Nothing+            , Resp.t_last = 0+            , Resp.type_ = "r"+            , Resp.stats = Nothing+            , Resp.parsedStats = Nothing+            , Resp.plan = Nothing+            , Resp.profile = Nothing+            , Resp.notifications = Nothing+            , Resp.parsedNotifications = V.empty+            , Resp.parsedPlan = Nothing+            , Resp.parsedProfile = Nothing+            , Resp.db = "neo4j"+            }+      in (length (show m) > 0) `shouldBe` True+++adminHelperTests :: TopSpec+adminHelperTests = describe "Database.Bolty.Admin outcome helpers" $ do++  it "describeReadOutcome prints ReadOK without inspecting fields" $+    -- Build a fully-zeroed ReadOK; the helper should not need to print it.+    let stub = ReadOK emptyResultSet emptyMeta+    in describeReadOutcome stub `shouldBe` "ReadOK"++  it "describeReadOutcome includes the stats line for ReadAbortedByWrite" $+    describeReadOutcome (ReadAbortedByWrite zeroStats) `shouldBe`+      "ReadAbortedByWrite (Stats: +nodes=0 -nodes=0 +rels=0 -rels=0 props=0 \+      \+labels=0 -labels=0 +idx=0 -idx=0 +constr=0 -constr=0)"++  it "describeWriteOutcome prints WriteCommitted without inspecting fields" $+    describeWriteOutcome (WriteCommitted emptyResultSet emptyMeta) `shouldBe` "WriteCommitted"++  it "describeWriteOutcome shows expected/actual when stats are absent" $+    describeWriteOutcome (WriteAbortedMismatch 5 3 Nothing) `shouldBe`+      "WriteAbortedMismatch expected=5 actual=3"++  it "describeWriteOutcome appends the stats line when present" $+    describeWriteOutcome (WriteAbortedMismatch 5 3 (Just zeroStats)) `shouldBe`+      "WriteAbortedMismatch expected=5 actual=3 (Stats: +nodes=0 -nodes=0 +rels=0 -rels=0 props=0 \+      \+labels=0 -labels=0 +idx=0 -idx=0 +constr=0 -constr=0)"+++-- Helpers for the Tx-helper tests above.++emptyResultSet :: RS.ResultSet+emptyResultSet = RS.ResultSet V.empty V.empty++emptyMeta :: QueryMeta+emptyMeta = QueryMeta+  { Resp.bookmark = Nothing+  , Resp.t_last = 0+  , Resp.type_ = "r"+  , Resp.stats = Nothing+  , Resp.parsedStats = Nothing+  , Resp.plan = Nothing+  , Resp.profile = Nothing+  , Resp.notifications = Nothing+  , Resp.parsedNotifications = V.empty+  , Resp.parsedPlan = Nothing+  , Resp.parsedProfile = Nothing+  , Resp.db = "neo4j"+  }++ -- | Assert a QuickCheck result, failing the sandwich test on property failure. assertQC r   | isSuccess r = pure ()@@ -2236,6 +2598,7 @@   messageTests   logonLogoffTests   structureRoundTripTests+  accessModeTests   poolConfigTests   retryTests   routeTests@@ -2265,3 +2628,4 @@   batchedPullTests   maxConnectionLifetimeTests   aesonTests+  adminHelperTests