packages feed

freckle-app-1.15.0.0: library/Freckle/App/Csv.hs

{-# LANGUAGE AllowAmbiguousTypes #-}
{-# LANGUAGE CPP #-}

-- | Stream, parse, and validate CSVs
--
-- A minor extension of [cassava](https://hackage.haskell.org/package/cassava).
-- Using `MonadValidate` and `Conduit`.
module Freckle.App.Csv
  ( csvWithValidationSink
  , csvWithParserAndValidationSink

    -- * Conduit Primitives
  , runCsvConduit
  , decodeCsv

    -- * Header Validation
  , ValidateHeader
  , validateHeader
  , hasHeader
  , defaultValidateOrderedHeader

    -- * Exceptions
  , CsvException (..)

    -- * Options
  , defaultOptions
  ) where

import Freckle.App.Prelude

import Conduit
import Control.Monad (foldM)
import Control.Monad.Validate
  ( MonadValidate (..)
  , Validate
  , ValidateT
  , refute
  , runValidate
  , runValidateT
  )
import Data.Aeson (KeyValue (..), ToJSON (..), object, pairs, (.=))
import Data.ByteString (ByteString)
import qualified Data.ByteString.Char8 as BS8
import qualified Data.Conduit.Combinators as Conduit
import qualified Data.Conduit.Text as Conduit
import Data.Csv
  ( DefaultOrdered
  , FromNamedRecord (..)
  , Header
  , Name
  , NamedRecord
  , Parser
  , defaultDecodeOptions
  , defaultOptions
  , headerOrder
  )
import qualified Data.Csv.Incremental as CsvI
import Data.Functor.Bind (Bind)
import qualified Data.HashMap.Strict as HashMap
import qualified Data.List.NonEmpty as NE
import Data.Proxy (Proxy (Proxy))
import Data.Sequence (Seq)
import qualified Data.Sequence as Seq
import Data.Sequence.NonEmpty (NESeq)
import qualified Data.Text as T
import qualified Data.Text.Encoding as T
import qualified Data.Vector as V

-- | Treat CSV header line as 1
--
-- CSVs can break rows over lines, but we don't currently handle that.
headerLineNumber :: Int
headerLineNumber = 1

class ValidateHeader a where
  validateHeader
    :: (Bind m, Monad m) => proxy a -> Header -> ValidateT (NESeq String) m ()

hasHeader :: Monad m => Header -> Name -> ValidateT (NESeq String) m ()
hasHeader h name
  | name `V.elem` h = pure ()
  | otherwise = refute . pure $ BS8.unpack name

defaultValidateOrderedHeader
  :: forall a proxy m
   . (DefaultOrdered a, Monad m)
  => proxy a
  -> Header
  -> ValidateT (NESeq String) m ()
defaultValidateOrderedHeader _ h =
  traverse_ (h `hasHeader`) $ headerOrder (error "old school haskell" :: a)

-- | Stream parse a CSV
--
-- - Expects UTF-8
-- - Provides incremental validation
csvWithValidationSink
  :: forall a b err m
   . ( MonadThrow m
     , MonadUnliftIO m
     , PrimMonad m
     , ValidateHeader a
     , FromNamedRecord a
     )
  => ConduitT () ByteString (ResourceT m) ()
  -- ^ CSV as a byte stream
  -> (Vector a -> Validate (NonEmpty (CsvException err)) (Vector b))
  -- ^ Validation to apply to resulting rows
  -> m (Validate (NonEmpty (CsvException err)) (Vector b))
csvWithValidationSink =
  csvWithParserAndValidationSink (validateHeader (Proxy @a)) parseNamedRecord

-- | Stream parse a CSV with a custom parser
--
-- - Expects UTF-8
-- - Provides incremental validation
csvWithParserAndValidationSink
  :: forall a b err m
   . (MonadThrow m, MonadUnliftIO m, PrimMonad m)
  => (Header -> Validate (NESeq String) ())
  -> (NamedRecord -> Parser a)
  -- ^ Custom record parser
  -> ConduitT () ByteString (ResourceT m) ()
  -- ^ CSV as a byte stream
  -> (Vector a -> Validate (NonEmpty (CsvException err)) (Vector b))
  -- ^ Validation to apply to resulting rows
  -> m (Validate (NonEmpty (CsvException err)) (Vector b))
csvWithParserAndValidationSink headerValidator p source validation = do
  validatedCsv <-
    runCsvConduit $
      transPipe lift source
        .| decodeCsvWithP headerValidator p
        .| transPipe lift sinkVector

  pure $ case validatedCsv of
    Left errs -> refute $ NE.fromList $ toList errs
    Right rows -> validation rows

-- | Run a CSV conduit handling invalid UTF8
runCsvConduit
  :: forall r m err
   . MonadUnliftIO m
  => ConduitT () Void (ValidateT (Seq (CsvException err)) (ResourceT m)) r
  -> m (Either (Seq (CsvException err)) r)
runCsvConduit = flip catch nonUtf8 . runResourceT . runValidateT . runConduit
 where
  nonUtf8 (_ :: Conduit.TextException) =
    pure $ Left $ pure CsvUnknownFileEncoding

-- | Stream in 'ByteString's and parse records in constant space
decodeCsv
  :: forall a m err
   . ( MonadThrow m
     , MonadValidate (Seq (CsvException err)) m
     , ValidateHeader a
     , FromNamedRecord a
     )
  => ConduitT ByteString a m ()
decodeCsv = decodeCsvWithP (validateHeader (Proxy @a)) parseNamedRecord

-- | Stream in 'ByteString's and parse records in constant space with a custom
-- record parser
decodeCsvWithP
  :: forall a m err
   . (MonadThrow m, MonadValidate (Seq (CsvException err)) m)
  => (Header -> Validate (NESeq String) ())
  -> (NamedRecord -> Parser a)
  -- ^ Custom record parser
  -> ConduitT ByteString a m ()
decodeCsvWithP headerValidator p =
  Conduit.detectUtf -- Strip any BOMs and decode UTF-*
    .| Conduit.encodeUtf8 -- Cassava needs ByteString
    .| parseCsv headerValidator p

parseCsv
  :: forall a m err
   . MonadValidate (Seq (CsvException err)) m
  => (Header -> Validate (NESeq String) ())
  -> (NamedRecord -> Parser a)
  -> ConduitT ByteString a m ()
parseCsv headerValidator p =
  parseHeader
    headerValidator
    (CsvI.decodeByNameWithP (stripParser p) defaultDecodeOptions)

data CsvException a
  = CsvMissingColumn !Text
  | CsvParseException !Int !Text
  | CsvFileNotFound
  | CsvUnknownFileEncoding
  | -- | A constructor for providing extensible csv exceptions
    CsvExceptionExtension a
  deriving stock (Eq, Show)

instance ToJSON a => ToJSON (CsvException a) where
  toJSON = csvExceptionPairs object toJSON
  toEncoding = csvExceptionPairs (pairs . mconcat) toEncoding

csvExceptionPairs
#if MIN_VERSION_aeson(2,2,0)
  :: KeyValue e kv
#else
  :: KeyValue kv
#endif
  => ([kv] -> r)
  -> (a -> r)
  -> CsvException a
  -> r
csvExceptionPairs done extend = \case
  CsvMissingColumn column ->
    done
      [ "message" .= ("Missing column " <> tshow column)
      , "missingColumn" .= column
      ]
  CsvParseException rowNumber message ->
    done ["rowNumber" .= rowNumber, "message" .= message]
  CsvFileNotFound -> done ["message" .= ("file not found" :: Text)]
  CsvUnknownFileEncoding ->
    done ["message" .= ("file could not be decoded" :: Text)]
  CsvExceptionExtension a -> extend a

parseHeader
  :: forall a m err
   . MonadValidate (Seq (CsvException err)) m
  => (Header -> Validate (NESeq String) ())
  -> CsvI.HeaderParser (CsvI.Parser a)
  -> ConduitT ByteString a m ()
parseHeader headerValidator = \case
  CsvI.FailH _ err ->
    lift $ refute $ pure $ CsvParseException headerLineNumber $ pack err
  CsvI.PartialH k ->
    await >>= (parseHeader headerValidator . k) . fromMaybe mempty
  CsvI.DoneH header parser ->
    case runValidate $ headerValidator $ fmap stripUtf8 header of
      Right {} -> parseRow (succ headerLineNumber) parser
      Left errs ->
        lift $ refute $ Seq.fromList $ CsvMissingColumn . pack <$> toList errs

parseRow
  :: MonadValidate (Seq (CsvException err)) m
  => Int
  -> CsvI.Parser a
  -> ConduitT ByteString a m ()
parseRow rowNumber parse = case parse of
  CsvI.Fail _ err ->
    lift $ dispute $ pure $ CsvParseException rowNumber $ pack err
  CsvI.Many rows k -> do
    !newRowNumber <- handleRows rows
    await >>= parseRow newRowNumber . k . fromMaybe mempty
  CsvI.Done rows -> void $ handleRows rows
 where
  handleRows = foldM handleRow rowNumber

handleRow
  :: MonadValidate (Seq (CsvException err)) m
  => Int
  -> Either String a
  -> ConduitT i a m Int
handleRow rowNumber result = do
  either
    (lift . dispute . pure . CsvParseException rowNumber . pack)
    yield
    result
  pure $ rowNumber + 1

-- | Strip leading/trailing whitespace from each key-value pairs
stripNamedRecord :: NamedRecord -> NamedRecord
stripNamedRecord =
  HashMap.fromList . fmap (bimap stripUtf8 stripUtf8) . HashMap.toList

-- | Strip leading/trailing whitespace from @'ByteString'@ via UTF-8
stripUtf8 :: ByteString -> ByteString
stripUtf8 = T.encodeUtf8 . T.strip . T.decodeUtf8

-- | Take a custom parser and return one that strips prior to parsing
stripParser :: (NamedRecord -> Parser a) -> (NamedRecord -> Parser a)
stripParser p = p . stripNamedRecord