packages feed

bolty-0.2.0.0: src/Database/Bolty/Value/Type.hs

-- | 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
  , asNull, asBool, asInt, asFloat, asBytes, asText
  , asList, asDict, asNode, asRelationship, asPath
    -- * Graph types
  , Node(..)
  , Relationship(..)
  , UnboundRelationship(..)
  , Path(..)
    -- * Temporal types
  , Date(..)
  , Time(..)
  , LocalTime(..)
  , DateTime(..)
  , DateTimeZoneId(..)
  , LocalDateTime(..)
  , Duration(..)
    -- * Spatial types
  , Point2D(..)
  , Point3D(..)
    -- * Aeson interop
    --
    -- $aesonInterop
  , parsePs
  , psToAeson
  , propsToAeson
  , propsFromAeson
  ) where

import           Control.Applicative    ((<|>), empty)
import           Data.Int              (Int64)
import           Data.Kind             (Type)
import           GHC.Generics          (Generic)
import qualified Data.ByteString       as BS
import qualified Data.HashMap.Lazy     as H
import qualified Data.Text             as T
import qualified Data.Vector           as V

import           Data.PackStream.Ps hiding ((.=), (.:))
import           Data.PackStream.Result  (Result(..))
import           Data.PackStream.Integer (PSInteger, fromIntegerTry)
import           Database.Bolty.Value.Helpers (sigNode, sigRel, sigURel, sigPath
                                              , sigDate, sigTime, sigLocalTime, sigDateTime
                                              , sigDateTimeZoneId, sigLocalDateTime, sigDuration
                                              , sigPoint2D, sigPoint3D)
import           Numeric                (showHex)

import qualified Data.Aeson              as Aeson
import qualified Data.Aeson.Key          as Key
import qualified Data.Aeson.KeyMap       as KM
import           Data.Aeson              (ToJSON(..), FromJSON(..), (.=), (.:))
import           Data.Aeson.Types        (Parser)
import qualified Data.ByteString.Base64  as B64
import qualified Data.Text.Encoding      as TE
import           Data.Scientific         (Scientific, fromFloatDigits, floatingOrInteger, toRealFloat)


-- | A typed Neo4j value. Every field in a query result record is a 'Bolt'.
type Bolt :: Type
data Bolt
  = BoltNull
  | BoltBoolean !Bool
  | BoltInteger !PSInteger
  | BoltFloat !Double
  | BoltBytes !BS.ByteString
  | BoltString !T.Text
  | BoltList !(V.Vector Bolt)
  | BoltDictionary !(H.HashMap T.Text Bolt)
  | BoltNode Node
  | BoltRelationship Relationship
  | BoltUnboundRelationship UnboundRelationship
  | BoltPath Path
  | BoltDate Date
  | BoltTime Time
  | BoltLocalTime LocalTime
  | BoltDateTime DateTime
  | BoltDateTimeZoneId DateTimeZoneId
  | BoltLocalDateTime LocalDateTime
  | BoltDuration Duration
  | BoltPoint2D Point2D
  | BoltPoint3D Point3D
  deriving stock (Show, Eq, Generic)

instance PackStream Bolt where
  toPs BoltNull                    = PsNull
  toPs (BoltBoolean b)             = PsBoolean b
  toPs (BoltInteger n)             = PsInteger n
  toPs (BoltFloat d)               = PsFloat d
  toPs (BoltBytes b)               = PsBytes b
  toPs (BoltString t)              = PsString t
  toPs (BoltList v)                = PsList (V.map toPs v)
  toPs (BoltDictionary m)          = PsDictionary (H.map toPs m)
  toPs (BoltNode n)                = toPs n
  toPs (BoltRelationship r)        = toPs r
  toPs (BoltUnboundRelationship r) = toPs r
  toPs (BoltPath p)                = toPs p
  toPs (BoltDate d)                = toPs d
  toPs (BoltTime t)                = toPs t
  toPs (BoltLocalTime t)           = toPs t
  toPs (BoltDateTime dt)           = toPs dt
  toPs (BoltDateTimeZoneId dt)     = toPs dt
  toPs (BoltLocalDateTime dt)      = toPs dt
  toPs (BoltDuration d)            = toPs d
  toPs (BoltPoint2D p)             = toPs p
  toPs (BoltPoint3D p)             = toPs p

  fromPs PsNull         = Success BoltNull
  fromPs (PsBoolean b)  = Success (BoltBoolean b)
  fromPs (PsInteger n)  = Success (BoltInteger n)
  fromPs (PsFloat d)    = Success (BoltFloat d)
  fromPs (PsBytes b)    = Success (BoltBytes b)
  fromPs (PsString t)   = Success (BoltString t)
  fromPs (PsList v)     = BoltList <$> traverse fromPs v
  fromPs (PsDictionary m) = BoltDictionary <$> traverse fromPs m
  fromPs ps@(PsStructure t _) = case t of
    _ | t == sigNode    -> BoltNode <$> fromPs ps
      | t == sigRel     -> BoltRelationship <$> fromPs ps
      | t == sigURel    -> BoltUnboundRelationship <$> fromPs ps
      | t == sigPath    -> BoltPath <$> fromPs ps
      | t == sigDate    -> BoltDate <$> fromPs ps
      | t == sigTime    -> BoltTime <$> fromPs ps
      | t == sigLocalTime    -> BoltLocalTime <$> fromPs ps
      | t == sigDateTime     -> BoltDateTime <$> fromPs ps
      | t == sigDateTimeZoneId -> BoltDateTimeZoneId <$> fromPs ps
      | t == sigLocalDateTime  -> BoltLocalDateTime <$> fromPs ps
      | t == sigDuration -> BoltDuration <$> fromPs ps
      | t == sigPoint2D  -> BoltPoint2D <$> fromPs ps
      | t == sigPoint3D  -> BoltPoint3D <$> fromPs ps
      | otherwise -> Error $ "unknown structure tag: 0x" <> T.pack (showHex t "")


-- | A graph node.
type Node :: Type
data Node = Node
  { id         :: !Int64
  -- ^ Legacy numeric ID (use 'element_id' for BOLT 5+).
  , labels     :: !(V.Vector T.Text)
  -- ^ Node labels.
  , properties :: !(H.HashMap T.Text Ps)
  -- ^ Node properties.
  , element_id :: !T.Text
  -- ^ Element ID string (empty on BOLT 4.x).
  }
  deriving stock (Show, Eq, Generic)
instance PackStream Node where
  toPs Node{id, labels, properties, element_id} =
    PsStructure sigNode $ V.fromList [toPs id, toPs labels, toPs properties, toPs element_id]
  -- BOLT 5+: 4 fields (id, labels, properties, element_id)
  fromPs (PsStructure t fs) | t == sigNode, V.length fs == 4 =
    Node <$> fromPs (fs V.! 0) <*> fromPs (fs V.! 1) <*> fromPs (fs V.! 2) <*> fromPs (fs V.! 3)
  -- BOLT 4.x: 3 fields (id, labels, properties) — no element_id
  fromPs (PsStructure t fs) | t == sigNode, V.length fs == 3 =
    Node <$> fromPs (fs V.! 0) <*> fromPs (fs V.! 1) <*> fromPs (fs V.! 2) <*> pure ""
  fromPs other = typeMismatch "Node" other

-- | A graph relationship (edge).
type Relationship :: Type
data Relationship = Relationship
  { id                     :: !Int64
  -- ^ Legacy numeric ID.
  , startNodeId            :: !Int64
  -- ^ Legacy numeric ID of the start node.
  , endNodeId              :: !Int64
  -- ^ Legacy numeric ID of the end node.
  , type_                  :: !T.Text
  -- ^ Relationship type name.
  , properties             :: !(H.HashMap T.Text Ps)
  -- ^ Relationship properties.
  , element_id             :: !T.Text
  -- ^ Element ID string (empty on BOLT 4.x).
  , start_node_element_id  :: !T.Text
  -- ^ Start node element ID (empty on BOLT 4.x).
  , end_node_element_id    :: !T.Text
  -- ^ End node element ID (empty on BOLT 4.x).
  }
  deriving stock (Show, Eq, Generic)
instance PackStream Relationship where
  toPs Relationship{id, startNodeId, endNodeId, type_, properties, element_id, start_node_element_id, end_node_element_id} =
    PsStructure sigRel $ V.fromList
      [toPs id, toPs startNodeId, toPs endNodeId, toPs type_, toPs properties
      , toPs element_id, toPs start_node_element_id, toPs end_node_element_id]
  -- BOLT 5+: 8 fields (id, startNodeId, endNodeId, type_, properties, element_id, start_node_element_id, end_node_element_id)
  fromPs (PsStructure t fs) | t == sigRel, V.length fs == 8 =
    Relationship <$> fromPs (fs V.! 0) <*> fromPs (fs V.! 1) <*> fromPs (fs V.! 2)
                 <*> fromPs (fs V.! 3) <*> fromPs (fs V.! 4) <*> fromPs (fs V.! 5)
                 <*> fromPs (fs V.! 6) <*> fromPs (fs V.! 7)
  -- BOLT 4.x: 5 fields (id, startNodeId, endNodeId, type_, properties) — no element_id fields
  fromPs (PsStructure t fs) | t == sigRel, V.length fs == 5 =
    Relationship <$> fromPs (fs V.! 0) <*> fromPs (fs V.! 1) <*> fromPs (fs V.! 2)
                 <*> fromPs (fs V.! 3) <*> fromPs (fs V.! 4) <*> pure "" <*> pure "" <*> pure ""
  fromPs other = typeMismatch "Relationship" other


-- | A relationship without start\/end node information (used in paths).
type UnboundRelationship :: Type
data UnboundRelationship = UnboundRelationship
  { id         :: !Int64
  , type_      :: !T.Text
  -- ^ Relationship type name.
  , properties :: !(H.HashMap T.Text Ps)
  , element_id :: !T.Text
  -- ^ Element ID string (empty on BOLT 4.x).
  }
  deriving stock (Show, Eq, Generic)
instance PackStream UnboundRelationship where
  toPs UnboundRelationship{id, type_, properties, element_id} =
    PsStructure sigURel $ V.fromList [toPs id, toPs type_, toPs properties, toPs element_id]
  -- BOLT 5+: 4 fields (id, type_, properties, element_id)
  fromPs (PsStructure t fs) | t == sigURel, V.length fs == 4 =
    UnboundRelationship <$> fromPs (fs V.! 0) <*> fromPs (fs V.! 1) <*> fromPs (fs V.! 2) <*> fromPs (fs V.! 3)
  -- BOLT 4.x: 3 fields (id, type_, properties) — no element_id
  fromPs (PsStructure t fs) | t == sigURel, V.length fs == 3 =
    UnboundRelationship <$> fromPs (fs V.! 0) <*> fromPs (fs V.! 1) <*> fromPs (fs V.! 2) <*> pure ""
  fromPs other = typeMismatch "UnboundRelationship" other


-- | A graph path: an alternating sequence of nodes and relationships.
type Path :: Type
data Path = Path
  { nodes   :: !(V.Vector Node)
  -- ^ Unique nodes in the path.
  , rels    :: !(V.Vector UnboundRelationship)
  -- ^ Unique relationships in the path.
  , indices :: !(V.Vector Int64)
  -- ^ Indices into 'nodes' and 'rels' describing the traversal order.
  }
  deriving stock (Show, Eq, Generic)
instance PackStream Path where
  toPs Path{nodes, rels, indices} =
    PsStructure sigPath $ V.fromList [toPs nodes, toPs rels, toPs indices]
  fromPs (PsStructure t fs) | t == sigPath, V.length fs == 3 =
    Path <$> fromPs (fs V.! 0) <*> fromPs (fs V.! 1) <*> fromPs (fs V.! 2)
  fromPs other = typeMismatch "Path" other


-- | A date (days since Unix epoch).
type Date :: Type
data Date = Date
  { days :: !Int64
  }
  deriving stock (Show, Eq, Generic)
instance PackStream Date where
  toPs Date{days} =
    PsStructure sigDate $ V.singleton (toPs days)
  fromPs (PsStructure t fs) | t == sigDate, V.length fs == 1 =
    Date <$> fromPs (fs V.! 0)
  fromPs other = typeMismatch "Date" other


-- | A time with timezone offset.
type Time :: Type
data Time = Time
  { nanoseconds       :: !Int64
  -- ^ Nanoseconds since midnight.
  , tz_offset_seconds :: !Int64
  -- ^ Timezone offset in seconds from UTC.
  }
  deriving stock (Show, Eq, Generic)
instance PackStream Time where
  toPs Time{nanoseconds, tz_offset_seconds} =
    PsStructure sigTime $ V.fromList [toPs nanoseconds, toPs tz_offset_seconds]
  fromPs (PsStructure t fs) | t == sigTime, V.length fs == 2 =
    Time <$> fromPs (fs V.! 0) <*> fromPs (fs V.! 1)
  fromPs other = typeMismatch "Time" other


-- | A local time (no timezone).
type LocalTime :: Type
data LocalTime = LocalTime
  { nanoseconds :: !Int64
  -- ^ Nanoseconds since midnight.
  }
  deriving stock (Show, Eq, Generic)
instance PackStream LocalTime where
  toPs LocalTime{nanoseconds} =
    PsStructure sigLocalTime $ V.singleton (toPs nanoseconds)
  fromPs (PsStructure t fs) | t == sigLocalTime, V.length fs == 1 =
    LocalTime <$> fromPs (fs V.! 0)
  fromPs other = typeMismatch "LocalTime" other


-- | A date-time with timezone offset.
type DateTime :: Type
data DateTime = DateTime
  { seconds           :: !Int64
  -- ^ Seconds since Unix epoch.
  , nanoseconds       :: !Int64
  -- ^ Nanosecond adjustment.
  , tz_offset_seconds :: !Int64
  -- ^ Timezone offset in seconds from UTC.
  }
  deriving stock (Show, Eq, Generic)
instance PackStream DateTime where
  toPs DateTime{seconds, nanoseconds, tz_offset_seconds} =
    PsStructure sigDateTime $ V.fromList [toPs seconds, toPs nanoseconds, toPs tz_offset_seconds]
  fromPs (PsStructure t fs) | t == sigDateTime, V.length fs == 3 =
    DateTime <$> fromPs (fs V.! 0) <*> fromPs (fs V.! 1) <*> fromPs (fs V.! 2)
  fromPs other = typeMismatch "DateTime" other


-- | A date-time with named timezone (e.g. @\"Europe\/Paris\"@).
type DateTimeZoneId :: Type
data DateTimeZoneId = DateTimeZoneId
  { seconds     :: !Int64
  -- ^ Seconds since Unix epoch.
  , nanoseconds :: !Int64
  -- ^ Nanosecond adjustment.
  , tz_id       :: !T.Text
  -- ^ IANA timezone identifier.
  }
  deriving stock (Show, Eq, Generic)
instance PackStream DateTimeZoneId where
  toPs DateTimeZoneId{seconds, nanoseconds, tz_id} =
    PsStructure sigDateTimeZoneId $ V.fromList [toPs seconds, toPs nanoseconds, toPs tz_id]
  fromPs (PsStructure t fs) | t == sigDateTimeZoneId, V.length fs == 3 =
    DateTimeZoneId <$> fromPs (fs V.! 0) <*> fromPs (fs V.! 1) <*> fromPs (fs V.! 2)
  fromPs other = typeMismatch "DateTimeZoneId" other


-- | A local date-time (no timezone).
type LocalDateTime :: Type
data LocalDateTime = LocalDateTime
  { seconds     :: !Int64
  -- ^ Seconds since Unix epoch.
  , nanoseconds :: !Int64
  -- ^ Nanosecond adjustment.
  }
  deriving stock (Show, Eq, Generic)
instance PackStream LocalDateTime where
  toPs LocalDateTime{seconds, nanoseconds} =
    PsStructure sigLocalDateTime $ V.fromList [toPs seconds, toPs nanoseconds]
  fromPs (PsStructure t fs) | t == sigLocalDateTime, V.length fs == 2 =
    LocalDateTime <$> fromPs (fs V.! 0) <*> fromPs (fs V.! 1)
  fromPs other = typeMismatch "LocalDateTime" other


-- | A temporal duration (months, days, seconds, nanoseconds).
type Duration :: Type
data Duration = Duration
  { months      :: !Int64
  , days        :: !Int64
  , seconds     :: !Int64
  , nanoseconds :: !Int64
  }
  deriving stock (Show, Eq, Generic)
instance PackStream Duration where
  toPs Duration{months, days, seconds, nanoseconds} =
    PsStructure sigDuration $ V.fromList [toPs months, toPs days, toPs seconds, toPs nanoseconds]
  fromPs (PsStructure t fs) | t == sigDuration, V.length fs == 4 =
    Duration <$> fromPs (fs V.! 0) <*> fromPs (fs V.! 1) <*> fromPs (fs V.! 2) <*> fromPs (fs V.! 3)
  fromPs other = typeMismatch "Duration" other


-- | A 2D spatial point.
type Point2D :: Type
data Point2D = Point2D
  { srid :: !Int64
  -- ^ Spatial Reference Identifier (e.g. 4326 for WGS-84).
  , x    :: !Double
  , y    :: !Double
  }
  deriving stock (Show, Eq, Generic)
instance PackStream Point2D where
  toPs Point2D{srid, x, y} =
    PsStructure sigPoint2D $ V.fromList [toPs srid, toPs x, toPs y]
  fromPs (PsStructure t fs) | t == sigPoint2D, V.length fs == 3 =
    Point2D <$> fromPs (fs V.! 0) <*> fromPs (fs V.! 1) <*> fromPs (fs V.! 2)
  fromPs other = typeMismatch "Point2D" other


-- | A 3D spatial point.
type Point3D :: Type
data Point3D = Point3D
  { srid :: !Int64
  -- ^ Spatial Reference Identifier (e.g. 4979 for WGS-84-3D).
  , x    :: !Double
  , y    :: !Double
  , z    :: !Double
  }
  deriving stock (Show, Eq, Generic)
instance PackStream Point3D where
  toPs Point3D{srid, x, y, z} =
    PsStructure sigPoint3D $ V.fromList [toPs srid, toPs x, toPs y, toPs z]
  fromPs (PsStructure t fs) | t == sigPoint3D, V.length fs == 4 =
    Point3D <$> fromPs (fs V.! 0) <*> fromPs (fs V.! 1) <*> fromPs (fs V.! 2) <*> fromPs (fs V.! 3)
  fromPs other = typeMismatch "Point3D" other


-- = 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
psToAeson PsNull           = Aeson.Null
psToAeson (PsBoolean b)    = Aeson.Bool b
psToAeson (PsInteger n)    = Aeson.Number (fromIntegral n)
psToAeson (PsFloat d)
  | isNaN d || isInfinite d = Aeson.Null
  | otherwise               = Aeson.Number (fromFloatDigits d)
psToAeson (PsString t)     = Aeson.String t
psToAeson (PsBytes bs)     = Aeson.object ["bytes" .= TE.decodeLatin1 (B64.encode bs)]
psToAeson (PsList v)       = Aeson.Array (V.map psToAeson v)
psToAeson (PsDictionary m) = Aeson.Object $ KM.fromList
  [(Key.fromText k, psToAeson v) | (k, v) <- H.toList m]
psToAeson (PsStructure _ _) = Aeson.Null  -- structures never appear in Neo4j properties

-- | Parse a JSON value as a PackStream property value.
parsePs :: Aeson.Value -> Parser Ps
parsePs Aeson.Null       = pure PsNull
parsePs (Aeson.Bool b)   = pure (PsBoolean b)
parsePs (Aeson.Number n) = pure (scientificToPs n)
parsePs (Aeson.String t) = pure (PsString t)
parsePs (Aeson.Array v)  = PsList <$> traverse parsePs v
parsePs (Aeson.Object obj) = case KM.toList obj of
  [(k, Aeson.String b64)] | Key.toText k == "bytes" ->
    case B64.decode (TE.encodeUtf8 b64) of
      Right bs -> pure (PsBytes bs)
      Left _   -> parsePsDictionary  -- not valid base64, treat as regular dictionary
  _ -> parsePsDictionary
  where
    parsePsDictionary = PsDictionary . H.fromList <$>
      traverse (\(k, v) -> (,) (Key.toText k) <$> parsePs v) (KM.toList obj)

-- | Convert a Scientific to a PackStream value.
scientificToPs :: Scientific -> Ps
scientificToPs n = case floatingOrInteger n :: Either Double Integer of
  Right i -> case fromIntegerTry i of
    Right psi -> PsInteger psi
    Left _    -> PsFloat (toRealFloat n)
  Left d    -> PsFloat d

-- | Convert a Scientific to a Bolt value.
scientificToBolt :: Scientific -> Bolt
scientificToBolt n = case floatingOrInteger n :: Either Double Integer of
  Right i -> case fromIntegerTry i of
    Right psi -> BoltInteger psi
    Left _    -> BoltFloat (toRealFloat n)
  Left d    -> BoltFloat d

-- | Encode a property map to JSON.
propsToAeson :: H.HashMap T.Text Ps -> Aeson.Value
propsToAeson m = Aeson.Object $ KM.fromList
  [(Key.fromText k, psToAeson v) | (k, v) <- H.toList m]

-- | Parse a JSON object as a property map.
propsFromAeson :: Aeson.Value -> Parser (H.HashMap T.Text Ps)
propsFromAeson = Aeson.withObject "properties" $ \obj ->
  H.fromList <$> traverse (\(k, v) -> (,) (Key.toText k) <$> parsePs v) (KM.toList obj)


instance ToJSON Node where
  toJSON Node{..} = Aeson.object
    [ "id"         .= id
    , "labels"     .= labels
    , "properties" .= propsToAeson properties
    , "element_id" .= element_id
    ]

instance FromJSON Node where
  parseJSON = Aeson.withObject "Node" $ \o ->
    Node <$> o .: "id"
         <*> o .: "labels"
         <*> (propsFromAeson =<< o .: "properties")
         <*> o .: "element_id"


instance ToJSON Relationship where
  toJSON Relationship{..} = Aeson.object
    [ "id"                    .= id
    , "startNodeId"           .= startNodeId
    , "endNodeId"             .= endNodeId
    , "type"                  .= type_
    , "properties"            .= propsToAeson properties
    , "element_id"            .= element_id
    , "start_node_element_id" .= start_node_element_id
    , "end_node_element_id"   .= end_node_element_id
    ]

instance FromJSON Relationship where
  parseJSON = Aeson.withObject "Relationship" $ \o ->
    Relationship <$> o .: "id"
                 <*> o .: "startNodeId"
                 <*> o .: "endNodeId"
                 <*> o .: "type"
                 <*> (propsFromAeson =<< o .: "properties")
                 <*> o .: "element_id"
                 <*> o .: "start_node_element_id"
                 <*> o .: "end_node_element_id"


instance ToJSON UnboundRelationship where
  toJSON UnboundRelationship{..} = Aeson.object
    [ "id"         .= id
    , "type"       .= type_
    , "properties" .= propsToAeson properties
    , "element_id" .= element_id
    ]

instance FromJSON UnboundRelationship where
  parseJSON = Aeson.withObject "UnboundRelationship" $ \o ->
    UnboundRelationship <$> o .: "id"
                        <*> o .: "type"
                        <*> (propsFromAeson =<< o .: "properties")
                        <*> o .: "element_id"


instance ToJSON Path where
  toJSON Path{..} = Aeson.object
    [ "nodes"         .= nodes
    , "relationships" .= rels
    , "indices"       .= indices
    ]

instance FromJSON Path where
  parseJSON = Aeson.withObject "Path" $ \o ->
    Path <$> o .: "nodes"
         <*> o .: "relationships"
         <*> o .: "indices"


instance ToJSON Date where
  toJSON Date{..} = Aeson.object ["days" .= days]

instance FromJSON Date where
  parseJSON = Aeson.withObject "Date" $ \o ->
    Date <$> o .: "days"


instance ToJSON Time where
  toJSON Time{..} = Aeson.object
    [ "nanoseconds"       .= nanoseconds
    , "tz_offset_seconds" .= tz_offset_seconds
    ]

instance FromJSON Time where
  parseJSON = Aeson.withObject "Time" $ \o ->
    Time <$> o .: "nanoseconds"
         <*> o .: "tz_offset_seconds"


instance ToJSON LocalTime where
  toJSON LocalTime{..} = Aeson.object ["nanoseconds" .= nanoseconds]

instance FromJSON LocalTime where
  parseJSON = Aeson.withObject "LocalTime" $ \o ->
    LocalTime <$> o .: "nanoseconds"


instance ToJSON DateTime where
  toJSON DateTime{..} = Aeson.object
    [ "seconds"           .= seconds
    , "nanoseconds"       .= nanoseconds
    , "tz_offset_seconds" .= tz_offset_seconds
    ]

instance FromJSON DateTime where
  parseJSON = Aeson.withObject "DateTime" $ \o ->
    DateTime <$> o .: "seconds"
             <*> o .: "nanoseconds"
             <*> o .: "tz_offset_seconds"


instance ToJSON DateTimeZoneId where
  toJSON DateTimeZoneId{..} = Aeson.object
    [ "seconds"     .= seconds
    , "nanoseconds" .= nanoseconds
    , "tz_id"       .= tz_id
    ]

instance FromJSON DateTimeZoneId where
  parseJSON = Aeson.withObject "DateTimeZoneId" $ \o ->
    DateTimeZoneId <$> o .: "seconds"
                   <*> o .: "nanoseconds"
                   <*> o .: "tz_id"


instance ToJSON LocalDateTime where
  toJSON LocalDateTime{..} = Aeson.object
    [ "seconds"     .= seconds
    , "nanoseconds" .= nanoseconds
    ]

instance FromJSON LocalDateTime where
  parseJSON = Aeson.withObject "LocalDateTime" $ \o ->
    LocalDateTime <$> o .: "seconds"
                  <*> o .: "nanoseconds"


instance ToJSON Duration where
  toJSON Duration{..} = Aeson.object
    [ "months"      .= months
    , "days"        .= days
    , "seconds"     .= seconds
    , "nanoseconds" .= nanoseconds
    ]

instance FromJSON Duration where
  parseJSON = Aeson.withObject "Duration" $ \o ->
    Duration <$> o .: "months"
             <*> o .: "days"
             <*> o .: "seconds"
             <*> o .: "nanoseconds"


instance ToJSON Point2D where
  toJSON Point2D{..} = Aeson.object
    [ "srid" .= srid
    , "x"    .= x
    , "y"    .= y
    ]

instance FromJSON Point2D where
  parseJSON = Aeson.withObject "Point2D" $ \o ->
    Point2D <$> o .: "srid"
            <*> o .: "x"
            <*> o .: "y"


instance ToJSON Point3D where
  toJSON Point3D{..} = Aeson.object
    [ "srid" .= srid
    , "x"    .= x
    , "y"    .= y
    , "z"    .= z
    ]

instance FromJSON Point3D where
  parseJSON = Aeson.withObject "Point3D" $ \o ->
    Point3D <$> o .: "srid"
            <*> o .: "x"
            <*> o .: "y"
            <*> o .: "z"


instance ToJSON Bolt where
  toJSON BoltNull             = Aeson.Null
  toJSON (BoltBoolean b)      = Aeson.Bool b
  toJSON (BoltInteger n)      = Aeson.Number (fromIntegral n)
  toJSON (BoltFloat d)
    | isNaN d || isInfinite d = Aeson.Null
    | otherwise               = Aeson.Number (fromFloatDigits d)
  toJSON (BoltBytes bs)       = Aeson.object ["bytes" .= TE.decodeLatin1 (B64.encode bs)]
  toJSON (BoltString t)       = Aeson.String t
  toJSON (BoltList v)         = toJSON v
  toJSON (BoltDictionary m)   = toJSON m
  toJSON (BoltNode n)                = Aeson.object ["node" .= n]
  toJSON (BoltRelationship r)        = Aeson.object ["relationship" .= r]
  toJSON (BoltUnboundRelationship r) = Aeson.object ["unboundRelationship" .= r]
  toJSON (BoltPath p)                = Aeson.object ["path" .= p]
  toJSON (BoltDate d)                = Aeson.object ["date" .= d]
  toJSON (BoltTime t)                = Aeson.object ["time" .= t]
  toJSON (BoltLocalTime t)           = Aeson.object ["localTime" .= t]
  toJSON (BoltDateTime dt)           = Aeson.object ["dateTime" .= dt]
  toJSON (BoltDateTimeZoneId dt)     = Aeson.object ["dateTimeZoneId" .= dt]
  toJSON (BoltLocalDateTime dt)      = Aeson.object ["localDateTime" .= dt]
  toJSON (BoltDuration d)            = Aeson.object ["duration" .= d]
  toJSON (BoltPoint2D p)             = Aeson.object ["point2d" .= p]
  toJSON (BoltPoint3D p)             = Aeson.object ["point3d" .= p]

-- | Note: JSON cannot distinguish @BoltFloat 1.0@ from @BoltInteger 1@.
-- Whole-number floats will round-trip as 'BoltInteger'. For lossless
-- serialization use the PackStream wire format instead.
instance FromJSON Bolt where
  parseJSON Aeson.Null     = pure BoltNull
  parseJSON (Aeson.Bool b) = pure (BoltBoolean b)
  parseJSON (Aeson.Number n) = pure (scientificToBolt n)
  parseJSON (Aeson.String t) = pure (BoltString t)
  parseJSON (Aeson.Array v)  = BoltList <$> traverse parseJSON v
  parseJSON (Aeson.Object obj) = case KM.toList obj of
    -- Single-key objects are tried as typed Bolt values first.
    -- On parse failure the object is treated as a plain BoltDictionary
    -- so that e.g. {"node": 42} does not reject — it round-trips as a
    -- dictionary rather than erroring out.
    [(key, val)] -> tryTyped (Key.toText key) val <|> parseBoltDictionary obj
    _            -> parseBoltDictionary obj
    where
      tryTyped "bytes"                v = parseBoltBytes v
      tryTyped "node"                 v = BoltNode <$> parseJSON v
      tryTyped "relationship"         v = BoltRelationship <$> parseJSON v
      tryTyped "unboundRelationship"  v = BoltUnboundRelationship <$> parseJSON v
      tryTyped "path"                 v = BoltPath <$> parseJSON v
      tryTyped "date"                 v = BoltDate <$> parseJSON v
      tryTyped "time"                 v = BoltTime <$> parseJSON v
      tryTyped "localTime"            v = BoltLocalTime <$> parseJSON v
      tryTyped "dateTime"             v = BoltDateTime <$> parseJSON v
      tryTyped "dateTimeZoneId"       v = BoltDateTimeZoneId <$> parseJSON v
      tryTyped "localDateTime"        v = BoltLocalDateTime <$> parseJSON v
      tryTyped "duration"             v = BoltDuration <$> parseJSON v
      tryTyped "point2d"              v = BoltPoint2D <$> parseJSON v
      tryTyped "point3d"              v = BoltPoint3D <$> parseJSON v
      tryTyped _                      _ = empty  -- unknown key, fall through to <|>

-- | Parse a base64-encoded bytes value.
parseBoltBytes :: Aeson.Value -> Parser Bolt
parseBoltBytes (Aeson.String b64) = case B64.decode (TE.encodeUtf8 b64) of
  Right bs -> pure (BoltBytes bs)
  Left _   -> fail "invalid base64 in bytes"
parseBoltBytes _ = fail "expected base64 string for bytes"

-- | Parse a JSON object as a BoltDictionary.
parseBoltDictionary :: KM.KeyMap Aeson.Value -> Parser Bolt
parseBoltDictionary obj = BoltDictionary . H.fromList <$>
  traverse (\(k, v) -> (,) (Key.toText k) <$> parseJSON v) (KM.toList obj)


-- = Bolt value extractors

-- | Extract a null value.
asNull :: Bolt -> Maybe ()
asNull BoltNull = Just ()
asNull _        = Nothing

-- | Extract a boolean value.
asBool :: Bolt -> Maybe Bool
asBool (BoltBoolean b) = Just b
asBool _               = Nothing

-- | Extract an integer value.
asInt :: Bolt -> Maybe PSInteger
asInt (BoltInteger i) = Just i
asInt _               = Nothing

-- | Extract a floating-point value.
asFloat :: Bolt -> Maybe Double
asFloat (BoltFloat d) = Just d
asFloat _             = Nothing

-- | Extract a byte string value.
asBytes :: Bolt -> Maybe BS.ByteString
asBytes (BoltBytes b) = Just b
asBytes _             = Nothing

-- | Extract a text value.
asText :: Bolt -> Maybe T.Text
asText (BoltString t) = Just t
asText _              = Nothing

-- | Extract a list value.
asList :: Bolt -> Maybe (V.Vector Bolt)
asList (BoltList v) = Just v
asList _            = Nothing

-- | Extract a dictionary value.
asDict :: Bolt -> Maybe (H.HashMap T.Text Bolt)
asDict (BoltDictionary m) = Just m
asDict _                  = Nothing

-- | Extract a node value.
asNode :: Bolt -> Maybe Node
asNode (BoltNode n) = Just n
asNode _            = Nothing

-- | Extract a relationship value.
asRelationship :: Bolt -> Maybe Relationship
asRelationship (BoltRelationship r) = Just r
asRelationship _                    = Nothing

-- | Extract a path value.
asPath :: Bolt -> Maybe Path
asPath (BoltPath p) = Just p
asPath _            = Nothing