dataframe-lazy-2.0.0.0: src/DataFrame/Lazy/IO/CSV.hs
{-# LANGUAGE BangPatterns #-}
{-# LANGUAGE ExplicitNamespaces #-}
{-# LANGUAGE GADTs #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RankNTypes #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TypeApplications #-}
module DataFrame.Lazy.IO.CSV where
import qualified Data.ByteString as BS
import qualified Data.Map as M
import qualified Data.Proxy as P
import qualified Data.Text as T
import qualified Data.Text.Encoding as TextEncoding
import qualified Data.Text.IO as TIO
import qualified Data.Vector as V
import qualified Data.Vector.Mutable as VM
import qualified Data.Vector.Unboxed.Mutable as VUM
import Control.Monad (forM_, unless, when, zipWithM_)
import Data.Attoparsec.Text (IResult (..), parseWith)
import Data.Char (intToDigit)
import Data.IORef
import Data.Maybe (fromMaybe, isJust)
import Data.Type.Equality (TestEquality (testEquality))
import Data.Word (Word8)
import DataFrame.IO.Internal.MutableColumn (freezeColumn', writeColumn)
import DataFrame.Internal.Column (
Column (..),
MutableColumn (..),
columnLength,
ensureOptional,
freezeColumnEither,
)
import DataFrame.Internal.DataFrame (DataFrame (..))
import DataFrame.Internal.Parsing
import DataFrame.Operations.Typing (SafeReadMode (..), effectiveSafeRead)
import DataFrame.Schema (Schema, SchemaType (..), elements)
import System.IO
import Type.Reflection
import Prelude hiding (takeWhile)
-- | Record for CSV read options.
data ReadOptions = ReadOptions
{ hasHeader :: Bool
, inferTypes :: Bool
, safeRead :: SafeReadMode
{- ^ Default 'SafeReadMode' for columns without an entry in
'safeReadOverrides'.
-}
, safeReadOverrides :: [(T.Text, SafeReadMode)]
-- ^ Per-column 'SafeReadMode' overrides; takes precedence over 'safeRead'.
, rowRange :: !(Maybe (Int, Int)) -- (start, length)
, seekPos :: !(Maybe Integer)
, totalRows :: !(Maybe Int)
, leftOver :: !T.Text
, rowsRead :: !Int
}
{- | Default read options: assume a header, infer types, no safe-read wrapping.
Set 'safeRead' to 'MaybeRead'/'EitherRead' to wrap columns; use
'safeReadOverrides' to pick a different mode per column.
-}
defaultOptions :: ReadOptions
defaultOptions =
ReadOptions
{ hasHeader = True
, inferTypes = True
, safeRead = NoSafeRead
, safeReadOverrides = []
, rowRange = Nothing
, seekPos = Nothing
, totalRows = Nothing
, leftOver = ""
, rowsRead = 0
}
{- | Reads a CSV file from the given path.
Note this file stores intermediate temporary files
while converting the CSV from a row to a columnar format.
-}
readCsv :: FilePath -> IO DataFrame
readCsv path = fst <$> readSeparated ',' defaultOptions path
{- | Reads a tab separated file from the given path.
Note this file stores intermediate temporary files
while converting the CSV from a row to a columnar format.
-}
readTsv :: FilePath -> IO DataFrame
readTsv path = fst <$> readSeparated '\t' defaultOptions path
-- | Reads a character separated file into a dataframe using mutable vectors.
readSeparated ::
Char -> ReadOptions -> FilePath -> IO (DataFrame, (Integer, T.Text, Int))
readSeparated c opts path = do
totalRows' <- case totalRows opts of
Nothing ->
countRows c path >>= \total -> if hasHeader opts then return (total - 1) else return total
Just n -> if hasHeader opts then return (n - 1) else return n
let (_, len') = case rowRange opts of
Nothing -> (0, totalRows')
Just (start, len'') -> (start, min len'' (totalRows' - rowsRead opts))
withFile path ReadMode $ \handle -> do
firstRow <- fmap T.strip . parseSep c <$> TIO.hGetLine handle
let columnNames =
if hasHeader opts
then fmap (T.filter (/= '\"')) firstRow
else fmap (T.singleton . intToDigit) [0 .. (length firstRow - 1)]
unless (hasHeader opts) $ hSeek handle AbsoluteSeek 0
currPos <- hTell handle
when (isJust $ seekPos opts) $
hSeek handle AbsoluteSeek (fromMaybe currPos (seekPos opts))
let numColumns = length columnNames
let numRows = len'
(dataRow, remainder) <- readSingleLine c (leftOver opts) handle
nullIndices <- VM.unsafeNew numColumns
VM.set nullIndices []
mutableCols <- VM.unsafeNew numColumns
getInitialDataVectors numRows mutableCols dataRow
(unconsumed, r) <-
fillColumns numRows c mutableCols nullIndices remainder handle
nulls' <- V.unsafeFreeze nullIndices
let !columnNamesV = V.fromList columnNames
cols <-
V.mapM
(freezeColumn columnNamesV mutableCols nulls' opts)
(V.generate numColumns id)
pos <- hTell handle
return
( DataFrame
{ columns = cols
, columnIndices = M.fromList (zip columnNames [0 ..])
, dataframeDimensions = (maybe 0 columnLength (cols V.!? 0), V.length cols)
, derivingExpressions = M.empty
}
, (pos, unconsumed, r + 1)
)
{-# INLINE readSeparated #-}
getInitialDataVectors :: Int -> VM.IOVector MutableColumn -> [T.Text] -> IO ()
getInitialDataVectors n mCol xs = do
forM_ (zip [0 ..] xs) $ \(i, x) -> do
col <- case inferValueType x of
"Int" ->
MUnboxedColumn
<$> ( (VUM.unsafeNew n :: IO (VUM.IOVector Int)) >>= \c -> VUM.unsafeWrite c 0 (fromMaybe 0 $ readInt x) >> return c
)
"Double" ->
MUnboxedColumn
<$> ( (VUM.unsafeNew n :: IO (VUM.IOVector Double)) >>= \c -> VUM.unsafeWrite c 0 (fromMaybe 0 $ readDouble x) >> return c
)
_ ->
MBoxedColumn
<$> ( (VM.unsafeNew n :: IO (VM.IOVector T.Text)) >>= \c -> VM.unsafeWrite c 0 x >> return c
)
VM.unsafeWrite mCol i col
{-# INLINE getInitialDataVectors #-}
-- | Reads rows from the handle and stores values in mutable vectors.
fillColumns ::
Int ->
Char ->
VM.IOVector MutableColumn ->
VM.IOVector [(Int, T.Text)] ->
T.Text ->
Handle ->
IO (T.Text, Int)
fillColumns n c mutableCols nullIndices unused handle = do
input <- newIORef unused
rowsRead' <- newIORef (0 :: Int)
forM_ [1 .. (n - 1)] $ \i -> do
atEOF <- hIsEOF handle
input' <- readIORef input
unless (atEOF && input' == mempty) $ do
parseWith (TIO.hGetChunk handle) (parseRow c) input' >>= \case
Fail _unconsumed ctx er -> do
erpos <- hTell handle
fail $
"Failed to parse CSV file around "
<> show erpos
<> " byte; due: "
<> show er
<> "; context: "
<> show ctx
Partial _ -> do
fail "Partial handler is called"
Done (unconsumed :: T.Text) (row :: [T.Text]) -> do
writeIORef input unconsumed
modifyIORef rowsRead' (+ 1)
zipWithM_ (writeValue mutableCols nullIndices i) [0 ..] row
l <- readIORef input
r <- readIORef rowsRead'
pure (l, r)
{-# INLINE fillColumns #-}
-- | Writes a value into the appropriate column, resizing the vector if necessary.
writeValue ::
VM.IOVector MutableColumn ->
VM.IOVector [(Int, T.Text)] ->
Int ->
Int ->
T.Text ->
IO ()
writeValue mutableCols nullIndices count colIndex value = do
col <- VM.unsafeRead mutableCols colIndex
res <- writeColumn count value col
let modify val = VM.unsafeModify nullIndices ((count, val) :) colIndex
either modify (const (return ())) res
{-# INLINE writeValue #-}
-- | Freezes a mutable vector into an immutable one, trimming it to the actual row count.
freezeColumn ::
V.Vector T.Text ->
VM.IOVector MutableColumn ->
V.Vector [(Int, T.Text)] ->
ReadOptions ->
Int ->
IO Column
freezeColumn colNames mutableCols nulls opts colIndex = do
col <- VM.unsafeRead mutableCols colIndex
let colNulls = nulls V.! colIndex
mode =
effectiveSafeRead
(safeRead opts)
(safeReadOverrides opts)
(colNames V.! colIndex)
case mode of
EitherRead -> freezeColumnEither colNulls col
MaybeRead -> do
frozen <- freezeColumn' colNulls col
return $! ensureOptional frozen
NoSafeRead -> freezeColumn' colNulls col
{-# INLINE freezeColumn #-}
-- ---------------------------------------------------------------------------
-- Streaming scan API
-- ---------------------------------------------------------------------------
{- | Open a file for streaming: returns a handle positioned after the header
and the column spec for the schema columns present in the header.
The caller must close the handle when done.
-}
openCsvStream ::
Char ->
Schema ->
FilePath ->
IO (Handle, [(Int, T.Text, SchemaType)])
openCsvStream sep schema path = do
handle <- openFile path ReadMode
hSetBuffering handle (BlockBuffering (Just (8 * 1024 * 1024)))
headerLine <- TIO.hGetLine handle
let headerCols = fmap (T.filter (/= '"') . T.strip) (parseSep sep headerLine)
let schemaMap = elements schema
let colSpec =
[ (idx, name, stype)
| (idx, name) <- zip [0 ..] headerCols
, Just stype <- [M.lookup name schemaMap]
]
when (null colSpec) $
hClose handle
>> fail
("openCsvStream: none of the schema columns appear in the header of " <> path)
return (handle, colSpec)
{- | Read up to @batchSz@ rows, returning a batch 'DataFrame' and the unconsumed
leftover text; 'Nothing' at EOF with no leftover. Pass the previous call's
leftover back in (use @""@ on the first call).
-}
readBatch ::
Char ->
[(Int, T.Text, SchemaType)] ->
Int ->
BS.ByteString ->
Handle ->
IO (Maybe (DataFrame, BS.ByteString))
readBatch sep colSpec batchSz leftover handle = do
let sepByte = fromIntegral (fromEnum sep) :: Word8
numCols = length colSpec
chunkSize = 8 * 1024 * 1024
nullsArr <- VM.unsafeNew numCols
VM.set nullsArr []
mCols <- VM.unsafeNew numCols
forM_ (zip [0 ..] colSpec) $ \(ci, (_, _, st)) ->
VM.unsafeWrite mCols ci =<< makeCol batchSz st
bufRef <- newIORef leftover
let loop !rowIdx = do
remaining <- readIORef bufRef
if rowIdx >= batchSz
then return (rowIdx, remaining)
else case findUnquotedNewline remaining of
Nothing -> do
chunk <- BS.hGet handle chunkSize
if BS.null chunk
then return (rowIdx, remaining)
else writeIORef bufRef (remaining <> chunk) >> loop rowIdx
Just nlIdx -> do
let line = BS.take nlIdx remaining
rest' = BS.drop (nlIdx + 1) remaining
line' =
if not (BS.null line) && BS.last line == 0x0D
then BS.init line
else line
writeIORef bufRef rest'
forM_ (zip [0 ..] colSpec) $ \(ci, (fi, _, _)) -> do
let fieldBs = getNthFieldBs sepByte fi line'
col <- VM.unsafeRead mCols ci
res <- writeColumnBs rowIdx fieldBs col
case res of
Left nv -> VM.unsafeModify nullsArr ((rowIdx, nv) :) ci
Right _ -> return ()
loop (rowIdx + 1)
(completeRows, newLeftover) <- loop 0
if completeRows == 0
then return Nothing
else do
forM_ [0 .. numCols - 1] $ \ci -> do
col <- VM.unsafeRead mCols ci
VM.unsafeWrite mCols ci (sliceCol completeRows col)
nullsVec <- V.unsafeFreeze nullsArr
cols <- V.generateM numCols $ \ci -> do
col <- VM.unsafeRead mCols ci
freezeColumn' (nullsVec V.! ci) col
let colNames = [name | (_, name, _) <- colSpec]
return $
Just
( DataFrame
{ columns = cols
, columnIndices = M.fromList (zip colNames [0 ..])
, dataframeDimensions = (completeRows, numCols)
, derivingExpressions = M.empty
}
, newLeftover
)
{- | Write a 'ByteString' field value directly into a mutable column,
parsing numerics without an intermediate 'T.Text' allocation.
-}
writeColumnBs ::
Int -> BS.ByteString -> MutableColumn -> IO (Either T.Text Bool)
writeColumnBs i bs (MBoxedColumn (col :: VM.IOVector a)) =
case testEquality (typeRep @a) (typeRep @T.Text) of
Just Refl ->
let val = TextEncoding.decodeUtf8Lenient bs
in VM.unsafeWrite col i val >> return (Right True)
Nothing -> return (Left (TextEncoding.decodeUtf8Lenient bs))
writeColumnBs i bs (MUnboxedColumn (col :: VUM.IOVector a)) =
case testEquality (typeRep @a) (typeRep @Double) of
Just Refl -> case readByteStringDouble bs of
Just v -> VUM.unsafeWrite col i v >> return (Right True)
Nothing -> VUM.unsafeWrite col i 0 >> return (Left (TextEncoding.decodeUtf8Lenient bs))
Nothing -> case testEquality (typeRep @a) (typeRep @Int) of
Just Refl -> case readByteStringInt bs of
Just v -> VUM.unsafeWrite col i v >> return (Right True)
Nothing -> VUM.unsafeWrite col i 0 >> return (Left (TextEncoding.decodeUtf8Lenient bs))
Nothing -> return (Left (TextEncoding.decodeUtf8Lenient bs))
{-# INLINE writeColumnBs #-}
{- | Extracts the Nth field (0-indexed), respecting double quotes and stripping them.
Fast path: uses memchr-based 'BS.break' when no quotes are present in the line.
Slow path: quote-aware character-by-character scan.
-}
getNthFieldBs :: Word8 -> Int -> BS.ByteString -> BS.ByteString
getNthFieldBs sep targetIdx bs
| not (BS.any (== 0x22) bs) = skipFast targetIdx bs
| otherwise = go 0 0 False 0
where
skipFast k s =
case BS.elemIndex sep s of
Nothing -> if k == 0 then s else BS.empty
Just i ->
if k == 0
then BS.take i s
else skipFast (k - 1) (BS.drop (i + 1) s)
quoteChar = 0x22 :: Word8
len = BS.length bs
go !idx !start !inQ !pos
| pos >= len =
if idx == targetIdx then extract start pos else BS.empty
| otherwise =
let c = BS.index bs pos
in if c == quoteChar
then go idx start (not inQ) (pos + 1)
else
if c == sep && not inQ
then
if idx == targetIdx
then extract start pos
else go (idx + 1) (pos + 1) False (pos + 1)
else go idx start inQ (pos + 1)
extract s e =
let fieldVal = BS.take (e - s) (BS.drop s bs)
in if BS.length fieldVal >= 2
&& BS.head fieldVal == quoteChar
&& BS.last fieldVal == quoteChar
then BS.init (BS.tail fieldVal)
else fieldVal
{-# INLINE getNthFieldBs #-}
-- | Allocate a fresh 'MutableColumn' for @n@ slots based on a 'SchemaType'.
makeCol :: Int -> SchemaType -> IO MutableColumn
makeCol n (SType (_ :: P.Proxy a)) =
case testEquality (typeRep @a) (typeRep @Int) of
Just Refl -> MUnboxedColumn <$> (VUM.unsafeNew n :: IO (VUM.IOVector Int))
Nothing -> case testEquality (typeRep @a) (typeRep @Double) of
Just Refl -> MUnboxedColumn <$> (VUM.unsafeNew n :: IO (VUM.IOVector Double))
Nothing -> MBoxedColumn <$> (VM.unsafeNew n :: IO (VM.IOVector T.Text))
-- | Slice a 'MutableColumn' to @n@ elements (no-copy view).
sliceCol :: Int -> MutableColumn -> MutableColumn
sliceCol n (MBoxedColumn col) = MBoxedColumn (VM.take n col)
sliceCol n (MUnboxedColumn col) = MUnboxedColumn (VUM.take n col)
{- | Finds the index of the next unquoted newline (0x0A).
Fast path: uses memchr (SIMD) and falls back to a quote-aware linear scan
only if a double-quote appears before the candidate newline.
-}
findUnquotedNewline :: BS.ByteString -> Maybe Int
findUnquotedNewline bs =
case BS.elemIndex 0x0A bs of
Nothing -> Nothing
Just nlPos
| maybe True (>= nlPos) (BS.elemIndex 0x22 bs) -> Just nlPos
| otherwise -> slowScan 0 False
where
len = BS.length bs
slowScan !pos !inQ
| pos >= len = Nothing
| otherwise =
let c = BS.index bs pos
in if c == 0x22
then slowScan (pos + 1) (not inQ)
else
if c == 0x0A && not inQ
then Just pos
else slowScan (pos + 1) inQ
{-# INLINE findUnquotedNewline #-}