packages feed

bolty-0.1.1.0: src/Database/Bolty/Decode.hs

{-# LANGUAGE UndecidableInstances #-}

-- | Type-safe decoding of query result records.
--
-- Instead of manually pattern-matching on 'Bolt' values, build composable
-- 'Decode' and 'RowDecoder' values that validate types at extraction time.
--
-- @
-- import Database.Bolty.Decode
--
-- data Person = Person { pName :: Text, pAge :: Int64 }
--
-- personDecoder :: 'RowDecoder' Person
-- personDecoder = Person
--   \<$\> 'field' \"name\" 'text'
--   \<*\> 'field' \"age\"  'int64'
-- @
module Database.Bolty.Decode
  ( DecodeError(..)
  , Decode(..)
  , RowDecoder(..)
  , FromBolt(..)
  , ToBolt(..)
    -- * Primitive decoders
  , bool, int, int64, float, text, bytes, nullable, list, dict
    -- * UUID decoder
  , uuid
    -- * Time decoders
  , utcTime, day, timeOfDay
    -- * Aeson decoder
  , aesonValue
    -- * Node property decoders
  , nodeProperty, nodePropertyOptional, nodeLabels, nodeProperties
    -- * PackStream interop
  , psDecode
    -- * Graph type decoders
  , node, relationship, path
    -- * Record-level decoders
  , column, field
    -- * Running decoders
  , decodeRow, decodeRows
    -- * Internal converters (exported for testing)
  , psToBolt, boltToPs
  ) where

import           Data.Kind               (Constraint, Type)
import           Control.Exception       (Exception)
import           TextShow                (TextShow(..), fromText)
import           Data.Int                (Int64)
import qualified Data.Aeson              as Aeson
import qualified Data.ByteString         as BS
import qualified Data.HashMap.Lazy       as H
import qualified Data.Text               as T
import qualified Data.UUID.Types         as UUID
import qualified Data.Vector             as V
import           Data.Aeson               (ToJSON(..))
import           Data.Time.Calendar.OrdinalDate (Day)
import           Data.Time.Clock         (UTCTime)
import           Data.Time.Clock.POSIX   (posixSecondsToUTCTime)
import           Data.Time.Format.ISO8601 (iso8601ParseM)
import           Data.Time.LocalTime     (TimeOfDay)


import           Data.PackStream.Integer (PSInteger, fromPSInteger)
import           Data.PackStream.Ps      (Ps(..), PackStream(..))
import           Data.PackStream.Result  (Result(..))
import           Database.Bolty.Value.Type
import           Database.Bolty.Value.Helpers (sigNode, sigRel, sigURel, sigPath
                                              , sigDate, sigTime, sigLocalTime, sigDateTime
                                              , sigDateTimeZoneId, sigLocalDateTime, sigDuration
                                              , sigPoint2D, sigPoint3D)
import           Database.Bolty.Record   (Record)


-- | Errors that can occur when decoding a 'Bolt' value or record row.
type DecodeError :: Type
data DecodeError
  = TypeMismatch T.Text T.Text
  -- ^ The value had a different type than expected (expected, got).
  | MissingField T.Text
  -- ^ A named field was not present in the column list.
  | IndexOutOfBounds Int Int
  -- ^ Column index was out of range (requested index, actual length).
  | EmptyResultSet
  -- ^ The result set contained no records.
  deriving stock (Show, Eq)

instance Exception DecodeError

instance TextShow DecodeError where
  showb (TypeMismatch e g) = "TypeMismatch {expected = \"" <> fromText e <> "\", got = \"" <> fromText g <> "\"}"
  showb (MissingField f)   = "MissingField \"" <> fromText f <> "\""
  showb (IndexOutOfBounds i l) = "IndexOutOfBounds " <> showb i <> " " <> showb l
  showb EmptyResultSet     = "EmptyResultSet"


-- | A decoder for a single 'Bolt' value.
type Decode :: Type -> Type
type role Decode representational
newtype Decode a = Decode { runDecode :: Bolt -> Either DecodeError a }

instance Functor Decode where
  fmap f (Decode g) = Decode (fmap f . g)

instance Applicative Decode where
  pure a = Decode (\_ -> Right a)
  Decode f <*> Decode a = Decode $ \b -> f b <*> a b

instance Monad Decode where
  Decode m >>= k = Decode $ \b -> do
    a <- m b
    runDecode (k a) b


-- | Helper to build a decoder from an extractor function.
fromExtractor :: T.Text -> (Bolt -> Maybe a) -> Decode a
fromExtractor name extract = Decode $ \b ->
  case extract b of
    Just a  -> Right a
    Nothing -> Left $ TypeMismatch name (boltTypeName b)

boltTypeName :: Bolt -> T.Text
boltTypeName BoltNull               = "Null"
boltTypeName (BoltBoolean _)        = "Boolean"
boltTypeName (BoltInteger _)        = "Integer"
boltTypeName (BoltFloat _)          = "Float"
boltTypeName (BoltBytes _)          = "Bytes"
boltTypeName (BoltString _)         = "String"
boltTypeName (BoltList _)           = "List"
boltTypeName (BoltDictionary _)     = "Dictionary"
boltTypeName (BoltNode _)           = "Node"
boltTypeName (BoltRelationship _)   = "Relationship"
boltTypeName (BoltUnboundRelationship _) = "UnboundRelationship"
boltTypeName (BoltPath _)           = "Path"
boltTypeName (BoltDate _)           = "Date"
boltTypeName (BoltTime _)           = "Time"
boltTypeName (BoltLocalTime _)      = "LocalTime"
boltTypeName (BoltDateTime _)       = "DateTime"
boltTypeName (BoltDateTimeZoneId _) = "DateTimeZoneId"
boltTypeName (BoltLocalDateTime _)  = "LocalDateTime"
boltTypeName (BoltDuration _)       = "Duration"
boltTypeName (BoltPoint2D _)        = "Point2D"
boltTypeName (BoltPoint3D _)        = "Point3D"


-- * Primitive decoders

-- | Decode a 'BoltBoolean'.
bool :: Decode Bool
bool = fromExtractor "Boolean" asBool

-- | Decode a 'BoltInteger' as a 'PSInteger'.
int :: Decode PSInteger
int = fromExtractor "Integer" asInt

-- | Decode a 'BoltInteger' narrowed to 'Int64'. Fails if the value is out of range.
int64 :: Decode Int64
int64 = Decode $ \b -> case asInt b of
  Just n  -> case fromPSInteger n of
    Just i  -> Right i
    Nothing -> Left $ TypeMismatch "Int64" "Integer (out of range)"
  Nothing -> Left $ TypeMismatch "Integer" (boltTypeName b)

-- | Decode a 'BoltFloat'.
float :: Decode Double
float = fromExtractor "Float" asFloat

-- | Decode a 'BoltString'.
text :: Decode T.Text
text = fromExtractor "String" asText

-- | Decode a 'BoltBytes'.
bytes :: Decode BS.ByteString
bytes = fromExtractor "Bytes" asBytes

-- | Make a decoder nullable: returns 'Nothing' for 'BoltNull', otherwise applies the inner decoder.
nullable :: Decode a -> Decode (Maybe a)
nullable (Decode f) = Decode $ \b -> case b of
  BoltNull -> Right Nothing
  _        -> Just <$> f b

-- | Decode a 'BoltList', applying the element decoder to each item.
list :: Decode a -> Decode (V.Vector a)
list (Decode f) = Decode $ \b -> case b of
  BoltList v -> traverse f v
  _          -> Left $ TypeMismatch "List" (boltTypeName b)

-- | Decode a 'BoltDictionary'.
dict :: Decode (H.HashMap T.Text Bolt)
dict = fromExtractor "Dictionary" asDict


-- * UUID decoder

-- | Decode a UUID from a 'BoltString'.
uuid :: Decode UUID.UUID
uuid = Decode $ \b -> case b of
  BoltString t -> case UUID.fromText t of
    Just u  -> Right u
    Nothing -> Left $ TypeMismatch "UUID" ("String (invalid UUID: " <> t <> ")")
  _ -> Left $ TypeMismatch "UUID (String)" (boltTypeName b)


-- * Time decoders

-- | Decode a 'UTCTime' from a 'BoltString' (ISO 8601) or 'BoltDateTime'.
utcTime :: Decode UTCTime
utcTime = Decode $ \b -> case b of
  BoltString t -> case iso8601ParseM (T.unpack t) of
    Just u  -> Right u
    Nothing -> Left $ TypeMismatch "UTCTime" ("String (invalid ISO8601: " <> t <> ")")
  BoltDateTime dt ->
    Right $ posixSecondsToUTCTime $ fromIntegral dt.seconds
  BoltDateTimeZoneId dt ->
    Right $ posixSecondsToUTCTime $ fromIntegral dt.seconds
  BoltLocalDateTime dt ->
    Right $ posixSecondsToUTCTime $ fromIntegral dt.seconds
  _ -> Left $ TypeMismatch "UTCTime (String/DateTime)" (boltTypeName b)

-- | Decode a 'Day' from a 'BoltString' (ISO 8601).
day :: Decode Day
day = Decode $ \b -> case b of
  BoltString t -> case iso8601ParseM (T.unpack t) of
    Just d  -> Right d
    Nothing -> Left $ TypeMismatch "Day" ("String (invalid ISO8601: " <> t <> ")")
  _ -> Left $ TypeMismatch "Day (String)" (boltTypeName b)

-- | Decode a 'TimeOfDay' from a 'BoltString' (ISO 8601).
timeOfDay :: Decode TimeOfDay
timeOfDay = Decode $ \b -> case b of
  BoltString t -> case iso8601ParseM (T.unpack t) of
    Just v  -> Right v
    Nothing -> Left $ TypeMismatch "TimeOfDay" ("String (invalid ISO8601: " <> t <> ")")
  _ -> Left $ TypeMismatch "TimeOfDay (String)" (boltTypeName b)


-- * Aeson decoder

-- | Decode any Bolt value to an 'Aeson.Value'.
--
-- Uses the 'ToJSON' instance on 'Bolt', which handles all constructors
-- including graph types, temporal types, and spatial types.
aesonValue :: Decode Aeson.Value
aesonValue = Decode (Right . toJSON)


-- * Node property decoders

-- | Extract a property from a 'BoltNode' by key and decode it.
nodeProperty :: T.Text -> Decode a -> Decode a
nodeProperty key (Decode f) = Decode $ \b -> case b of
  BoltNode n -> case H.lookup key n.properties of
    Just ps -> f (psToBolt ps)
    Nothing -> Left $ MissingField key
  _ -> Left $ TypeMismatch "Node" (boltTypeName b)

-- | Extract a property from a 'BoltNode' by key, returning 'Nothing' if the key is missing.
nodePropertyOptional :: T.Text -> Decode a -> Decode (Maybe a)
nodePropertyOptional key (Decode f) = Decode $ \b -> case b of
  BoltNode n -> case H.lookup key n.properties of
    Just ps -> Just <$> f (psToBolt ps)
    Nothing -> Right Nothing
  _ -> Left $ TypeMismatch "Node" (boltTypeName b)

-- | Extract labels from a 'BoltNode'.
nodeLabels :: Decode (V.Vector T.Text)
nodeLabels = Decode $ \b -> case b of
  BoltNode n -> Right n.labels
  _ -> Left $ TypeMismatch "Node" (boltTypeName b)

-- | Extract the raw properties HashMap from a 'BoltNode'.
nodeProperties :: Decode (H.HashMap T.Text Ps)
nodeProperties = Decode $ \b -> case b of
  BoltNode n -> Right n.properties
  _ -> Left $ TypeMismatch "Node" (boltTypeName b)


-- * PackStream interop

-- | Decode via the PackStream 'fromPs' roundtrip through a node property's Ps value.
-- Useful for types that already have PackStream instances.
psDecode :: PackStream a => Decode a
psDecode = Decode $ \b -> case fromPs (boltToPs b) of
  Success v -> Right v
  Error e   -> Left $ TypeMismatch "PackStream" e


-- * Graph type decoders

-- | Decode a 'BoltNode'.
node :: Decode Node
node = fromExtractor "Node" asNode

-- | Decode a 'BoltRelationship'.
relationship :: Decode Relationship
relationship = fromExtractor "Relationship" asRelationship

-- | Decode a 'BoltPath'.
path :: Decode Path
path = fromExtractor "Path" asPath


-- * Record-level decoders

-- | A decoder for a full result row. Compose with 'Applicative' to decode multiple columns.
type RowDecoder :: Type -> Type
type role RowDecoder representational
newtype RowDecoder a = RowDecoder
  { runRowDecoder :: V.Vector T.Text -> Record -> Either DecodeError a }

instance Functor RowDecoder where
  fmap f (RowDecoder g) = RowDecoder $ \cols rec -> f <$> g cols rec

instance Applicative RowDecoder where
  pure a = RowDecoder $ \_ _ -> Right a
  RowDecoder f <*> RowDecoder a = RowDecoder $ \cols rec ->
    f cols rec <*> a cols rec


-- | Types that can be decoded from a result row without an explicit decoder.
--
-- Implement this for your application types to use 'Database.Bolty.query'
-- without passing a 'RowDecoder' explicitly:
--
-- @
-- data Person = Person { name :: Text, age :: Int64 }
--
-- instance FromBolt Person where
--   rowDecoder = Person \<$\> field \"name\" text \<*\> field \"age\" int64
-- @
type FromBolt :: Type -> Constraint
class FromBolt a where
  rowDecoder :: RowDecoder a


-- | Types that can be encoded to a 'Ps' value for use as query parameters.
-- All 'PackStream' instances are automatically 'ToBolt' instances.
type ToBolt :: Type -> Constraint
class ToBolt a where
  toBolt :: a -> Ps

instance PackStream a => ToBolt a where
  toBolt = toPs


-- | Decode a value at a positional column index.
column :: Int -> Decode a -> RowDecoder a
column idx (Decode f) = RowDecoder $ \_ rec ->
  let len = V.length rec
  in if idx < 0 || idx >= len
     then Left $ IndexOutOfBounds idx len
     else f (rec V.! idx)

-- | Decode a value by column name.
field :: T.Text -> Decode a -> RowDecoder a
field name (Decode f) = RowDecoder $ \cols rec ->
  case V.elemIndex name cols of
    Nothing  -> Left $ MissingField name
    Just idx -> f (rec V.! idx)


-- * Running decoders

-- | Decode a single record row.
decodeRow :: RowDecoder a -> V.Vector T.Text -> Record -> Either DecodeError a
decodeRow (RowDecoder f) = f

-- | Decode all records in a result set. Fails on the first 'DecodeError'.
decodeRows :: RowDecoder a -> V.Vector T.Text -> V.Vector Record -> Either DecodeError (V.Vector a)
decodeRows decoder cols = traverse (decodeRow decoder cols)


-- * Internal helpers

-- | Convert a Ps value to a Bolt value.
psToBolt :: Ps -> Bolt
psToBolt PsNull           = BoltNull
psToBolt (PsBoolean b)    = BoltBoolean b
psToBolt (PsInteger n)    = BoltInteger n
psToBolt (PsFloat d)      = BoltFloat d
psToBolt (PsString t)     = BoltString t
psToBolt (PsBytes b)      = BoltBytes b
psToBolt (PsList v)       = BoltList $ V.map psToBolt v
psToBolt (PsDictionary m) = BoltDictionary $ H.map psToBolt m
psToBolt ps@(PsStructure t _)
  | t == sigNode           = tryFromPs BoltNode ps
  | t == sigRel            = tryFromPs BoltRelationship ps
  | t == sigURel           = tryFromPs BoltUnboundRelationship ps
  | t == sigPath           = tryFromPs BoltPath ps
  | t == sigDate           = tryFromPs BoltDate ps
  | t == sigTime           = tryFromPs BoltTime ps
  | t == sigLocalTime      = tryFromPs BoltLocalTime ps
  | t == sigDateTime       = tryFromPs BoltDateTime ps
  | t == sigDateTimeZoneId = tryFromPs BoltDateTimeZoneId ps
  | t == sigLocalDateTime  = tryFromPs BoltLocalDateTime ps
  | t == sigDuration       = tryFromPs BoltDuration ps
  | t == sigPoint2D        = tryFromPs BoltPoint2D ps
  | t == sigPoint3D        = tryFromPs BoltPoint3D ps
  | otherwise              = BoltNull
  where
    tryFromPs :: PackStream a => (a -> Bolt) -> Ps -> Bolt
    tryFromPs wrap p = case fromPs p of
      Success v -> wrap v
      _         -> BoltNull

-- | Convert a Bolt value to a Ps value.
boltToPs :: Bolt -> Ps
boltToPs BoltNull           = PsNull
boltToPs (BoltBoolean b)    = PsBoolean b
boltToPs (BoltInteger n)    = PsInteger n
boltToPs (BoltFloat d)      = PsFloat d
boltToPs (BoltString t)     = PsString t
boltToPs (BoltBytes b)      = PsBytes b
boltToPs (BoltList v)       = PsList $ V.map boltToPs v
boltToPs (BoltDictionary m) = PsDictionary $ H.map boltToPs m
boltToPs (BoltNode n)                = toPs n
boltToPs (BoltRelationship r)        = toPs r
boltToPs (BoltUnboundRelationship r) = toPs r
boltToPs (BoltPath p)                = toPs p
boltToPs (BoltDate d)                = toPs d
boltToPs (BoltTime t)                = toPs t
boltToPs (BoltLocalTime t)           = toPs t
boltToPs (BoltDateTime dt)           = toPs dt
boltToPs (BoltDateTimeZoneId dt)     = toPs dt
boltToPs (BoltLocalDateTime dt)      = toPs dt
boltToPs (BoltDuration d)            = toPs d
boltToPs (BoltPoint2D p)             = toPs p
boltToPs (BoltPoint3D p)             = toPs p