packages feed

cozo-hs-0.1.0.0: src/Database/Cozo.hs

{-# HLINT ignore "Use const" #-}
{-# HLINT ignore "Eta reduce" #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE StrictData #-}
{-# OPTIONS_GHC -Wno-typed-holes #-}
{-# OPTIONS_GHC -Wno-unrecognised-pragmas #-}

{- |
Module      : Database.Cozo
Description : Wrappers and types for the Cozo C API
License     : MPL-2.0
Maintainer  : hencutJohnson@gmail.com

Included are some wrapping functions for Cozo's C API and data types to deserialize them.
-}
module Database.Cozo (
  -- * Data
  CozoResult (..),
  CozoOkay (..),
  NamedRows (..),
  CozoBad (..),
  CozoRelationExportPayload (..),
  CozoException (..),

  -- * Functions
  open,
  close,
  runQuery,
  backup,
  restore,
  importRelations,
  exportRelations,
  importFromBackup,

  -- ** Lower Level Wrappers
  open',
  close',
  runQuery',
  backup',
  restore',
  importRelations',
  exportRelations',
  importFromBackup',

  -- * Re-exports
  Connection,
  CozoNullResultPtrException,
  Database.Cozo.Internal.InternalCozoError,
  Key,
  KeyMap,
  KM.empty,
  KM.singleton,
  KM.insert,
  KM.fromList,
  Value (..),
) where

import Control.Exception (Exception)
import Data.Aeson (
  FromJSON (parseJSON),
  Options (fieldLabelModifier),
  ToJSON (..),
  Value (..),
  defaultOptions,
  eitherDecodeStrict,
  fromEncoding,
  genericParseJSON,
  genericToEncoding,
  genericToJSON,
  withObject,
  (.:),
 )
import Data.Aeson.KeyMap (Key, KeyMap)
import Data.Aeson.KeyMap qualified as KM
import Data.Aeson.Types (Encoding, Parser)
import Data.Bifunctor (Bifunctor (bimap, first))
import Data.ByteString (ByteString, toStrict)
import Data.ByteString.Builder (toLazyByteString)
import Data.Char (toLower)
import Data.Text (Text)
import Data.Text.Encoding (encodeUtf8)
import Database.Cozo.Internal (
  Connection,
  CozoNullResultPtrException,
  InternalCozoError,
  backup',
  close',
  exportRelations',
  importFromBackup',
  importRelations',
  open',
  restore',
  runQuery',
 )
import GHC.Generics (Generic)

{- |
Relation information with headers, their values, and another `NamedRows` if
it exists.
-}
data NamedRows = NamedRows
  { namedRowsHeaders :: [Text]
  , namedRowsRows :: [[Value]]
  , namedRowsNext :: Maybe NamedRows
  }
  deriving (Show, Eq, Generic)

instance FromJSON NamedRows where
  parseJSON :: Value -> Parser NamedRows
  parseJSON =
    genericParseJSON
      ( defaultOptions
          { fieldLabelModifier = \s ->
              case drop 9 s of
                [] -> []
                x : xs -> toLower x : xs
          }
      )

instance ToJSON NamedRows where
  toJSON :: NamedRows -> Value
  toJSON =
    genericToJSON
      ( defaultOptions
          { fieldLabelModifier = \s ->
              case drop 9 s of
                [] -> []
                x : xs -> toLower x : xs
          }
      )
  toEncoding :: NamedRows -> Encoding
  toEncoding =
    genericToEncoding
      ( defaultOptions
          { fieldLabelModifier = \s ->
              case drop 9 s of
                [] -> []
                x : xs -> toLower x : xs
          }
      )

data ConstJSON = ConstJSON deriving (Show, Eq, Generic)

instance FromJSON ConstJSON where
  parseJSON :: Value -> Parser ConstJSON
  parseJSON _ = pure ConstJSON

{- |
A failure that cannot be recovered from easily.
-}
data CozoException
  = -- | An internal error may occur when a connection is first being established
    -- but not after that.
    CozoExceptionInternal InternalCozoError
  | -- | If any operation in the underlying C API returns a null pointer instead
    -- of a pointer to a valid string, this error will be returned.
    CozoErrorNullPtr CozoNullResultPtrException
  | -- | The result of any operation fails to be deserialized appropriately.
    -- This is a problem with the wrapper for the API and should be
    -- submitted as an issue if it ever arises.
    CozoJSONParseException String
  | -- | A non-query operation such as a backup or import failed.
    -- These usually occur because the user is trying to import or export a
    -- relation that does not exist in the target database.
    CozoOperationFailed Text
  deriving (Show, Eq, Generic)

instance Exception CozoException

newtype CozoMessage = CozoMessage {runCozoMessage :: Text}
  deriving (Show, Eq, Generic)

instance FromJSON CozoMessage where
  parseJSON :: Value -> Parser CozoMessage
  parseJSON =
    genericParseJSON
      ( defaultOptions
          { fieldLabelModifier = \s ->
              case drop 7 s of
                [] -> []
                x : xs -> toLower x : xs
          }
      )

cozoMessageToException :: CozoMessage -> CozoException
cozoMessageToException (CozoMessage m) = CozoOperationFailed m

{- |
A map of names and the relations they contain.
This type is intended to be used as input to an import function
or otherwise stored as JSON.
-}
newtype CozoRelationExportPayload = CozoRelationExportPayload
  { cozoRelationExportPayloadData :: KeyMap NamedRows
  }
  deriving (Show, Eq, Generic)

instance FromJSON CozoRelationExportPayload where
  parseJSON :: Value -> Parser CozoRelationExportPayload
  parseJSON =
    genericParseJSON
      ( defaultOptions
          { fieldLabelModifier = \s ->
              case drop 25 s of
                [] -> []
                x : xs -> toLower x : xs
          }
      )

{- |
An intermediate type for decoding structures that return an object with a 'message' field
when the 'ok' field is false.
-}
newtype IntermediateCozoMessageOnNotOK a = IntermediateCozoMessageOnNotOK
  { runIntermediateCozoMessageOnNotOK :: Either CozoMessage a
  }
  deriving (Show, Eq, Generic)

instance (FromJSON a) => FromJSON (IntermediateCozoMessageOnNotOK a) where
  parseJSON :: Value -> Parser (IntermediateCozoMessageOnNotOK a)
  parseJSON =
    eitherOkay
      "IntermediateCozoRelationExport"
      (fmap (IntermediateCozoMessageOnNotOK . Left) . parseJSON)
      (fmap (IntermediateCozoMessageOnNotOK . Right) . parseJSON)

data IntermediateCozoImportFromRelationInput = IntermediateCozoImportFromRelationInput
  { intermediateCozoImportFromRelationInputPath :: Text
  , intermediateCozoImportFromRelationInputRelations :: [Text]
  }
  deriving (Show, Eq, Generic)

instance ToJSON IntermediateCozoImportFromRelationInput where
  toJSON :: IntermediateCozoImportFromRelationInput -> Value
  toJSON =
    genericToJSON
      ( defaultOptions
          { fieldLabelModifier = \s ->
              case drop 39 s of
                [] -> []
                x : xs -> toLower x : xs
          }
      )
  toEncoding :: IntermediateCozoImportFromRelationInput -> Encoding
  toEncoding =
    genericToEncoding
      ( defaultOptions
          { fieldLabelModifier = \s ->
              case drop 39 s of
                [] -> []
                x : xs -> toLower x : xs
          }
      )

{- |
An intermediate type for packing a list of named relations into an object with the form
\"{'relations': [...]}\"
-}
newtype IntermediateCozoRelationInput = IntermediateCozoRelationInput
  { intermediateCozoRelationInputRelations :: [Text]
  }
  deriving (Show, Eq, Generic)

instance ToJSON IntermediateCozoRelationInput where
  toJSON :: IntermediateCozoRelationInput -> Value
  toJSON =
    genericToJSON
      ( defaultOptions
          { fieldLabelModifier = \s ->
              case drop 29 s of
                [] -> []
                x : xs -> toLower x : xs
          }
      )
  toEncoding ::
    IntermediateCozoRelationInput ->
    Encoding
  toEncoding =
    genericToEncoding
      ( defaultOptions
          { fieldLabelModifier = \s ->
              case drop 29 s of
                [] -> []
                x : xs -> toLower x : xs
          }
      )

{- |
An okay result from a query.

Contains result headers, and rows among other things.
-}
data CozoOkay = CozoOkay
  { cozoOkayNamedRows :: NamedRows
  , cozoOkayTook :: Double
  }
  deriving (Show, Eq, Generic)

instance FromJSON CozoOkay where
  parseJSON :: Value -> Parser CozoOkay
  parseJSON =
    withObject "CozoOkay" $ \o ->
      CozoOkay
        <$> parseJSON (Object o)
        <*> o
        .: "took"

{- |
A bad result from a query.

Contains information on what went wrong.
-}
data CozoBad = CozoBad
  { cozoBadDisplay :: Maybe Text
  , cozoBadMessage :: Text
  , cozoBadSeverity :: Maybe Text
  }
  deriving (Show, Eq, Generic)

instance FromJSON CozoBad where
  parseJSON :: Value -> Parser CozoBad
  parseJSON =
    genericParseJSON
      ( defaultOptions
          { fieldLabelModifier = \s ->
              case drop 7 s of
                [] -> []
                x : xs -> toLower x : xs
          }
      )

newtype CozoResult = CozoResult {runCozoResult :: Either CozoBad CozoOkay}
  deriving (Show, Eq, Generic)

instance FromJSON CozoResult where
  parseJSON :: Value -> Parser CozoResult
  parseJSON =
    eitherOkay
      "CozoResult"
      (fmap (CozoResult . Left) . parseJSON)
      (fmap (CozoResult . Right) . parseJSON)

{- |
Open a connection to a cozo database

- engine: \"mem\", \"sqlite\" or \"rocksdb\"
- path: utf8 encoded filepath
- options: engine-specific options. \"{}\" is an acceptable empty value.
-}
open :: Text -> Text -> Text -> IO (Either CozoException Connection)
open engine path options =
  first CozoExceptionInternal
    <$> open'
      (encodeUtf8 engine)
      (encodeUtf8 path)
      (encodeUtf8 options)

{- |
True if the database was closed and False if it was already closed or if it
does not exist.
-}
close :: Connection -> IO Bool
close = close'

{- |
Run a utf8 encoded query with a map of parameters.

Parameters are declared with
text names and can be any valid JSON type. They are referenced in a query by a \"$\"
preceding their name.
-}
runQuery ::
  Connection ->
  Text ->
  KeyMap Value ->
  IO (Either CozoException CozoResult)
runQuery c query params = do
  r <-
    runQuery'
      c
      (encodeUtf8 query)
      ( toStrict
          . toLazyByteString
          . fromEncoding
          . toEncoding
          $ params
      )
  pure $ first CozoErrorNullPtr r >>= cozoDecode

{- |
Backup a database.

Accepts the path of the output file.
-}
backup :: Connection -> Text -> IO (Either CozoException ())
backup c path =
  decodeCozoCharPtrFn
    <$> backup' c (encodeUtf8 path)

{- |
Restore a database from a backup.
-}
restore :: Connection -> Text -> IO (Either CozoException ())
restore c path =
  decodeCozoCharPtrFn
    <$> restore' c (encodeUtf8 path)

{- |
Import data in relations.

Triggers are not run for relations, if you wish to activate triggers, use a query
  with parameters.
-}
importRelations ::
  Connection ->
  CozoRelationExportPayload ->
  IO (Either CozoException ())
importRelations c (CozoRelationExportPayload km) =
  decodeCozoCharPtrFn
    <$> importRelations' c (strictToEncoding km)

{- |
Export the relations specified by the given names.
-}
exportRelations ::
  Connection ->
  [Text] ->
  IO (Either CozoException CozoRelationExportPayload)
exportRelations c bs = do
  r <-
    exportRelations'
      c
      ( strictToEncoding
          . IntermediateCozoRelationInput
          $ bs
      )
  pure
    $ first CozoErrorNullPtr r
    >>= first CozoJSONParseException
    . eitherDecodeStrict @(IntermediateCozoMessageOnNotOK CozoRelationExportPayload)
    >>= first cozoMessageToException
    . runIntermediateCozoMessageOnNotOK

{- |
Import the relations corresponding to the given names
from the specified path.
-}
importFromBackup :: Connection -> Text -> [Text] -> IO (Either CozoException ())
importFromBackup c path relations =
  decodeCozoCharPtrFn
    <$> importFromBackup'
      c
      (strictToEncoding $ IntermediateCozoImportFromRelationInput path relations)

decodeCozoCharPtrFn ::
  Either CozoNullResultPtrException ByteString ->
  Either CozoException ()
decodeCozoCharPtrFn e =
  first CozoErrorNullPtr e
    >>= cozoDecode @(IntermediateCozoMessageOnNotOK ConstJSON)
    >>= bimap cozoMessageToException (const ())
    . runIntermediateCozoMessageOnNotOK

cozoDecode :: (FromJSON a) => ByteString -> Either CozoException a
cozoDecode = first CozoJSONParseException . eitherDecodeStrict

{- |
Helper for defining JSON disjunctions that
switch on the value of an \"ok\" boolean field.
-}
eitherOkay ::
  String ->
  (Value -> Parser a) ->
  (Value -> Parser a) ->
  Value ->
  Parser a
eitherOkay s l r =
  withObject
    s
    ( \o ->
        case KM.lookup "ok" o of
          Nothing -> fail "Result did not contain \"ok\" field"
          Just ok ->
            case ok of
              Bool b ->
                if b then r (Object o) else l (Object o)
              _ -> fail "\"ok\" field did not contain a Boolean."
    )

strictToEncoding :: (ToJSON a) => a -> ByteString
strictToEncoding =
  toStrict
    . toLazyByteString
    . fromEncoding
    . toEncoding