dataframe-lazy-2.4.2.1: test/StreamingCsv.hs
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TypeApplications #-}
module Main (main) where
import Control.Exception (SomeException, bracket, try)
import Control.Monad (when)
import qualified Data.ByteString as BS
import qualified Data.ByteString.Char8 as C8
import Data.IORef (modifyIORef', newIORef, readIORef)
import Data.List (isInfixOf)
import qualified Data.Map.Strict as M
import qualified Data.Text as T
import qualified DataFrame.IO.CSV as Csv
import DataFrame.Internal.Column (Column (..))
import qualified DataFrame.Internal.DataFrame as D
import qualified DataFrame.Lazy as L
import DataFrame.Schema (Schema (..), schemaType)
import System.Directory (removeFile)
import qualified System.Exit as Exit
import System.IO.Temp (emptySystemTempFile)
import Test.HUnit
withRawCsv :: BS.ByteString -> (FilePath -> IO a) -> IO a
withRawCsv bytes =
bracket
( do
path <- emptySystemTempFile "lazy_streaming_.csv"
BS.writeFile path bytes
pure path
)
removeFile
-- | Regression: a path-only reader sees one complete 64 MiB-or-EOF window.
streamingPathReaderFillsWindow :: Test
streamingPathReaderFillsWindow =
TestLabel "streaming_path_reader_fills_window" $ TestCase $ do
let row = "1," <> BS.replicate 4094 0x78 <> "\n"
rowCount = 4096
input = "id,payload\n" <> BS.concat (replicate rowCount row)
schema = Schema $ M.fromList [("id", schemaType @Int)]
withRawCsv input $ \path -> do
calls <- newIORef (0 :: Int)
let countingReader opts windowPath = do
modifyIORef' calls (+ 1)
Csv.readSeparated opts windowPath
df <-
L.runDataFrame
(L.scanCsvStreamingWith countingReader schema (T.pack path))
assertEqual "all rows decoded" (rowCount, 1) (D.dataframeDimensions df)
readIORef calls >>= assertEqual "one parser window" 1
-- | A record newline exactly at the 64 MiB read boundary must not be lost.
streamingWindowBoundaryIsLossless :: Test
streamingWindowBoundaryIsLossless =
TestLabel "streaming_window_boundary_is_lossless" $ TestCase $ do
let windowBytes = 64 * 1024 * 1024
firstRecord = "1," <> BS.replicate (windowBytes - 3) 0x78 <> "\n"
input = BS.concat ["id,payload\n", firstRecord, "2,y\n"]
schema = Schema $ M.fromList [("id", schemaType @Int)]
withRawCsv input $ \path -> do
observations <- newIORef []
let observingReader _ bytes = do
let (header, withNewline) = BS.break (== 0x0A) bytes
body = BS.drop 1 withNewline
observation =
( C8.unpack header
, BS.length body
, BS.unpack (BS.take 2 body)
, if BS.null body then Nothing else Just (BS.last body)
)
modifyIORef' observations (observation :)
pure D.empty
_ <-
L.runDataFrame
(L.scanCsvStreamingBytesWith observingReader schema (T.pack path))
actual <- reverse <$> readIORef observations
assertEqual
"two complete records, each with the original header"
[ ("id,payload", windowBytes - 1, [0x31, 0x2C], Just 0x78)
, ("id,payload", 3, [0x32, 0x2C], Just 0x79)
]
actual
-- | A quoted newline spanning a 64 MiB boundary stays in one window.
streamingQuotedNewlineSpansWindow :: Test
streamingQuotedNewlineSpansWindow =
TestLabel "streaming_quoted_newline_spans_window" $ TestCase $ do
let windowBytes = 64 * 1024 * 1024
firstChunk = "1,\"" <> BS.replicate (windowBytes - 4) 0x78 <> "\n"
input = BS.concat ["id,payload\n", firstChunk, "\"\n"]
schema = Schema $ M.fromList [("id", schemaType @Int)]
withRawCsv input $ \path -> do
bodyLengths <- newIORef []
let observingReader _ bytes = do
let body = BS.drop 1 (snd (BS.break (== 0x0A) bytes))
modifyIORef' bodyLengths (BS.length body :)
pure D.empty
_ <-
L.runDataFrame
(L.scanCsvStreamingBytesWith observingReader schema (T.pack path))
actual <- reverse <$> readIORef bodyLengths
assertEqual
"quoted record stays in one parser window"
[windowBytes + 1]
actual
-- | The default streaming path strips a BOM and matches an eager read.
streamingDefaultMatchesEager :: Test
streamingDefaultMatchesEager =
TestLabel "streaming_default_matches_eager" $ TestCase $ do
let input = "\xEF\xBB\xBFid,name,value\n1,Ada,3.5\n2,Alan,4.5"
schema =
Schema $
M.fromList
[ ("id", schemaType @Int)
, ("name", schemaType @T.Text)
, ("value", schemaType @Double)
]
withRawCsv input $ \path -> do
let opts = Csv.schemaReadOptions schema
eager <- Csv.readSeparated opts path
streamed <- L.runDataFrame (L.scanCsvStreaming schema (T.pack path))
assertEqual "streaming bytes reader matches eager reader" eager streamed
-- | A parser failure on the producer thread must fail the consumer.
streamingReaderFailurePropagates :: Test
streamingReaderFailurePropagates =
TestLabel "streaming_reader_failure_propagates" $ TestCase $ do
let input = "id\n1\n"
schema = Schema $ M.fromList [("id", schemaType @Int)]
failingReader _ _ = ioError (userError "streaming-reader-marker")
withRawCsv input $ \path -> do
result <-
try
( L.runDataFrame
(L.scanCsvStreamingBytesWith failingReader schema (T.pack path))
)
case result of
Left (err :: SomeException) ->
assertBool
("unexpected exception: " <> show err)
("streaming-reader-marker" `isInfixOf` show err)
Right (_ :: D.DataFrame) -> assertFailure "expected reader failure"
-- | Schema-declared text stays in the CSV reader's packed representation.
streamingKeepsTextPacked :: Test
streamingKeepsTextPacked =
TestLabel "streaming_keeps_text_packed" $ TestCase $ do
let input =
"station,value\n"
<> "Alpha,1\nBeta,2\nAlpha,3\nBeta,4\n"
<> "Alpha,5\nBeta,6\nAlpha,7\nBeta,8\n"
schema = Schema $ M.fromList [("station", schemaType @T.Text)]
withRawCsv input $ \path -> do
df <-
L.runDataFrame
(L.scanCsvStreaming schema (T.pack path))
case D.unsafeGetColumn "station" df of
PackedText{} -> pure ()
_ -> assertFailure "expected the CSV text column to stay packed"
tests :: Test
tests =
TestList
[ streamingPathReaderFillsWindow
, streamingWindowBoundaryIsLossless
, streamingQuotedNewlineSpansWindow
, streamingDefaultMatchesEager
, streamingReaderFailurePropagates
, streamingKeepsTextPacked
]
main :: IO ()
main = do
result <- runTestTT tests
when (failures result > 0 || errors result > 0) Exit.exitFailure