dataframe-fastcsv-1.1.1.0: src/DataFrame/IO/CSV/Fast/Passes.hs
{-# LANGUAGE BangPatterns #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE MagicHash #-}
{-# LANGUAGE ScopedTypeVariables #-}
{- | The typed column passes (Round-2 WS-E1): each pass walks one column's
delimiter slices and appends parsed values straight into a WS-C builder —
no per-field 'Data.Text.Text' and no deferred re-parse. Text bytes are
memcpy'd into a shared buffer, only for Text-typed columns.
-}
module DataFrame.IO.CSV.Fast.Passes (
ColumnEnv (..),
NullSpec (..),
PassCol (..),
PassResult,
passColumn,
nullSpecFor,
isNullSlice,
isNullText,
rowAt,
intPass,
doublePass,
boolPass,
textPass,
datePass,
allNullPass,
) where
import qualified Data.ByteString as BS
import qualified Data.ByteString.Internal as BSI
import qualified Data.Text as T
import qualified Data.Text.Encoding as TE
import qualified Data.Vector as V
import qualified Data.Vector.Mutable as VM
import qualified Data.Vector.Storable as VS
import qualified Data.Vector.Unboxed as VU
import Control.Monad.ST (stToIO)
import Data.Word (Word8)
import Foreign.Ptr (Ptr, nullPtr, plusPtr)
import GHC.Exts (Int (..), Int#, isTrue#, (+#), (>=#))
import DataFrame.IO.CSV (ReadOptions (..))
import DataFrame.IO.CSV.Fast.Index (quote)
import DataFrame.IO.CSV.Fast.Slice
import DataFrame.Internal.Column (Column, Columnable, fromVector)
import DataFrame.Internal.ColumnBuilder (
ColumnBuilder (..),
TextChunk,
appendNum,
appendText,
appendTextSliceFromPtr,
freezeTextChunk,
mergeTextChunks,
newNumBuilder,
newTextBuilder,
)
import DataFrame.Internal.Parsing.Fast (
isMissingField,
isMissingFieldIn,
isMissingFieldSlice,
parseBoolFieldSlice,
parseDateFieldSlice,
parseDoubleFieldSlice,
parseIntFieldSlice,
)
import DataFrame.Operations.Typing (
SafeReadMode (..),
isNullishOrMissing,
parseTimeOpt,
)
-- | Per-read context shared by every column pass.
data ColumnEnv = ColumnEnv
{ ceCtx :: !FieldCtx
, ceRows :: !(VS.Vector Int)
-- ^ Row indices (into row-ends) of the data rows, blanks skipped.
, ceNumRow :: !Int
, cePtr :: !(Ptr Word8)
-- ^ Base pointer of the file content (valid for the whole read).
, ceTextHint :: !Int
-- ^ Initial byte capacity for a Text column builder.
, ceOpts :: !ReadOptions
}
-- | Byte-level null test: mode-dependent (audit T3, behaviors list #1).
data NullSpec
= -- | 'NoSafeRead': only the empty field is null.
NullEmpty
| -- | 'MaybeRead': canonical missing set plus custom extras.
NullMissing ![BS.ByteString] ![T.Text]
nullSpecFor :: ReadOptions -> SafeReadMode -> NullSpec
nullSpecFor _ NoSafeRead = NullEmpty
nullSpecFor opts _ =
NullMissing
(filter (not . isMissingField) (map TE.encodeUtf8 toks))
toks
where
toks = missingIndicators opts
{-# INLINE isNullSlice #-}
isNullSlice :: NullSpec -> BS.ByteString -> Int -> Int -> Bool
isNullSlice NullEmpty _ s e = e <= s
isNullSlice (NullMissing extras _) bs s e =
isMissingFieldSlice bs s e
|| (not (null extras) && isMissingFieldIn extras (sliceBS bs s e))
-- | Text-level twin of 'isNullSlice' for the decode-and-strip trim path.
isNullText :: NullSpec -> T.Text -> Bool
isNullText NullEmpty t = T.null t
isNullText (NullMissing _ toks) t = isNullishOrMissing toks t
{- | What a pass produced: a finished 'Column', or a raw text chunk that the
merger splices at the byte level (no per-chunk 'T.Text' materialization).
-}
data PassCol = PassFull !Column | PassText !TextChunk
-- | Finish one 'PassCol' on its own (the single-chunk \/ sequential case).
passColumn :: PassCol -> Column
passColumn (PassFull c) = c
passColumn (PassText tc) = mergeTextChunks [tc]
-- | Result of a fallible pass: @Left rowIndex@ on the first parse failure.
type PassResult = Either Int PassCol
rowAt :: ColumnEnv -> Int -> Int
rowAt env = VS.unsafeIndex (ceRows env)
{-# INLINE rowAt #-}
{- | One pass appending unboxed values; @nullOnFail@ turns parse failures
into nulls (schema + 'MaybeRead') instead of aborting (inference demotion).
-}
{-# INLINE unboxedPass #-}
unboxedPass ::
(Columnable a, VU.Unbox a) =>
ColumnEnv ->
NullSpec ->
Int ->
Bool ->
a ->
(BS.ByteString -> Int -> Int -> Maybe a) ->
IO PassResult
unboxedPass env nspec col nullOnFail nullVal parser = do
let ctx = ceCtx env
bs = fcBS ctx
!(I# n) = ceNumRow env
b <- stToIO (newNumBuilder nullVal (ceNumRow env))
-- Loop counter is Int#: join points are not worker/wrapper'd, so a
-- boxed counter would allocate on every iteration.
let go :: Int# -> IO PassResult
go i
| isTrue# (i >=# n) =
Right . PassFull <$> stToIO (freezeBuilder b)
| otherwise =
withParseSlice ctx (rowAt env (I# i)) col $ \s e ->
if isNullSlice nspec bs s e
then stToIO (appendNull b) >> go (i +# 1#)
else case parser bs s e of
Just v -> stToIO (appendNum b v) >> go (i +# 1#)
Nothing
| nullOnFail ->
stToIO (appendNull b) >> go (i +# 1#)
| otherwise -> pure (Left (I# i))
go 0#
intPass :: ColumnEnv -> NullSpec -> Int -> Bool -> IO PassResult
intPass env nspec col nullOnFail =
unboxedPass env nspec col nullOnFail (0 :: Int) parseIntFieldSlice
doublePass :: ColumnEnv -> NullSpec -> Int -> Bool -> IO PassResult
doublePass env nspec col nullOnFail =
unboxedPass env nspec col nullOnFail (0 :: Double) parseDoubleFieldSlice
boolPass :: ColumnEnv -> NullSpec -> Int -> Bool -> IO PassResult
boolPass env nspec col nullOnFail =
unboxedPass env nspec col nullOnFail False parseBoolFieldSlice
{- | Text pass (never fails): raw bytes are memcpy'd into the shared
builder buffer; only quoted fields containing a doubled quote, and the
legacy trim knob, take the decode path. Returns the raw chunk; 'T.Text'
values materialize once, when the merger splices the chunks.
-}
textPass :: ColumnEnv -> NullSpec -> Int -> IO TextChunk
textPass env nspec col = do
let ctx = ceCtx env
bs = fcBS ctx
ptr = cePtr env
!(I# n) = ceNumRow env
b <- stToIO (newTextBuilder (ceNumRow env) (ceTextHint env))
let go :: Int# -> IO TextChunk
go i
| isTrue# (i >=# n) = stToIO (freezeTextChunk b)
| otherwise =
withFieldSlice ctx (rowAt env (I# i)) col $ \s e q ->
if fcTrim ctx && not q
then do
let t = T.strip (decodeSlice bs s e)
if isNullText nspec t
then stToIO (appendNull b)
else stToIO (appendText b t)
go (i +# 1#)
else
if isNullSlice nspec bs s e
then stToIO (appendNull b) >> go (i +# 1#)
else do
hasQ <-
if q
then containsQuote ptr s e
else pure False
if hasQ
then
stToIO . appendText b $
unescapeDoubledQuotes
(decodeSlice bs s e)
else
stToIO $
appendTextSliceFromPtr
b
(ptr `plusPtr` s)
(e - s)
go (i +# 1#)
go 0#
-- | memchr for a doubled-quote candidate inside a quoted field's content.
containsQuote :: Ptr Word8 -> Int -> Int -> IO Bool
containsQuote ptr s e = do
p <- BSI.memchr (ptr `plusPtr` s) quote (fromIntegral (e - s))
pure (p /= nullPtr)
{-# INLINE containsQuote #-}
-- | Date pass: boxed (Day is not unboxable), mirrors @handleDateAssumption@.
datePass :: ColumnEnv -> NullSpec -> Int -> Bool -> IO PassResult
datePass env nspec col nullOnFail = do
let ctx = ceCtx env
bs = fcBS ctx
n = ceNumRow env
dfmt = dateFormat (ceOpts env)
parseAt s e
| dfmt == "%Y-%m-%d" = parseDateFieldSlice bs s e
| otherwise = parseTimeOpt dfmt (decodeSlice bs s e)
out <- VM.new n
let go !i !anyNull
| i >= n = do
vec <- V.unsafeFreeze out
pure . Right . PassFull $
if anyNull
then fromVector vec
else fromVector (V.mapMaybe id vec)
| otherwise =
withParseSlice ctx (rowAt env i) col $ \s e ->
if isNullSlice nspec bs s e
then VM.unsafeWrite out i Nothing >> go (i + 1) True
else case parseAt s e of
Just d ->
VM.unsafeWrite out i (Just d) >> go (i + 1) anyNull
Nothing
| nullOnFail ->
VM.unsafeWrite out i Nothing >> go (i + 1) True
| otherwise -> pure (Left i)
go (0 :: Int) False
-- | Is every data cell of the column null? (@handleNoAssumption@ head case.)
allNullPass :: ColumnEnv -> NullSpec -> Int -> IO Bool
allNullPass env nspec col = go 0
where
ctx = ceCtx env
bs = fcBS ctx
n = ceNumRow env
go !i
| i >= n = pure True
| otherwise =
withParseSlice ctx (rowAt env i) col $ \s e ->
if isNullSlice nspec bs s e then go (i + 1) else pure False