packages feed

dataframe-fastcsv-1.1.1.0: src/DataFrame/IO/CSV/Fast/Columns.hs

{-# LANGUAGE GADTs #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TupleSections #-}
{-# LANGUAGE TypeApplications #-}

{- | Column-level dispatch for the fast CSV reader: resolve each column's
target type up front into a 'ColumnPlan' (schema hit -> typed pass directly;
miss -> sample classification + typed fallback chain; EitherRead \/ exotic
schema types -> the legacy Text pipeline). Plans are chunk-runnable so the
parallel reader (WS-E2) can execute them over row sub-ranges.
-}
module DataFrame.IO.CSV.Fast.Columns (
    ColumnEnv (..),
    ColumnPlan (..),
    Step,
    TargetType,
    ChunkOutcome (..),
    buildColumn,
    planColumn,
    runChainChunk,
    runSchemaChunk,
    runStep,
    legacyColumn,
    allNullColumn,
    finishMode,
    schemaError,
) where

import qualified Data.Map as M
import qualified Data.Proxy as P
import qualified Data.Text as T
import qualified Data.Vector as V

import Control.Exception (ErrorCall (..), throwIO)
import Data.Maybe (isJust)
import Data.Time (Day)
import Data.Type.Equality (testEquality)
import Type.Reflection (typeRep)

import DataFrame.IO.CSV (
    ReadOptions (..),
    schemaTypeMap,
    shouldInferFromSample,
    typeInferenceSampleSize,
 )
import DataFrame.IO.CSV.Fast.Passes
import DataFrame.IO.CSV.Fast.Slice (extractField)
import DataFrame.Internal.Column (Column, ensureOptional, fromVector)
import DataFrame.Internal.Schema (SchemaType (..))
import DataFrame.Operations.Typing (
    ParseOptions (..),
    ParsingAssumption (..),
    SafeReadMode (..),
    effectiveSafeRead,
    isNullishOrMissing,
    makeParsingAssumption,
    parseFromExamples,
 )

-- | One step of a type-fallback chain (always ends in 'StepText').
data Step = StepBool | StepInt | StepDouble | StepDate | StepText

-- | Run one chain step over the env's row range as a single typed pass.
runStep :: ColumnEnv -> NullSpec -> Int -> Bool -> Step -> IO PassResult
runStep env nspec col nullOnFail step = case step of
    StepBool -> boolPass env nspec col nullOnFail
    StepInt -> intPass env nspec col nullOnFail
    StepDouble -> doublePass env nspec col nullOnFail
    StepDate -> datePass env nspec col nullOnFail
    StepText -> Right . PassText <$> textPass env nspec col

-- | The documented fallback chain for each inference assumption.
chainFor :: ParsingAssumption -> [Step]
chainFor BoolAssumption = [StepBool, StepText]
chainFor IntAssumption = [StepInt, StepDouble, StepText]
chainFor DoubleAssumption = [StepDouble, StepText]
chainFor DateAssumption = [StepDate, StepText]
chainFor TextAssumption = [StepText]
chainFor NoAssumption = [StepBool, StepInt, StepDouble, StepDate, StepText]

-- | Schema target types served by the direct typed passes.
data TargetType = TInt | TDouble | TText | TBool | TDate

targetName :: TargetType -> String
targetName TInt = "Int"
targetName TDouble = "Double"
targetName TText = "Text"
targetName TBool = "Bool"
targetName TDate = "Day"

stepForTarget :: TargetType -> Step
stepForTarget TInt = StepInt
stepForTarget TDouble = StepDouble
stepForTarget TText = StepText
stepForTarget TBool = StepBool
stepForTarget TDate = StepDate

supportedType :: SchemaType -> Maybe TargetType
supportedType (SType (_ :: P.Proxy a))
    | isJust (testEquality (typeRep @a) (typeRep @Int)) = Just TInt
    | isJust (testEquality (typeRep @a) (typeRep @Double)) = Just TDouble
    | isJust (testEquality (typeRep @a) (typeRep @T.Text)) = Just TText
    | isJust (testEquality (typeRep @a) (typeRep @Bool)) = Just TBool
    | isJust (testEquality (typeRep @a) (typeRep @Day)) = Just TDate
    | otherwise = Nothing

{- | How to build one column, resolved once per read (before any chunk
fan-out). 'PlanLegacy' carries the \"re-apply 'parseWithTypes'\" flag.
-}
data ColumnPlan
    = {- | Inference: fallback chain; the 'Bool' asks for the all-null
      pre-check ('NoAssumption' only).
      -}
      PlanChain !SafeReadMode !NullSpec ![Step] !Bool
    | PlanSchema !SafeReadMode !NullSpec !TargetType
    | PlanLegacy !SafeReadMode !Bool

{- | Resolve a column's plan. For inferred columns the sample is always
classified against the global (full-range) env, so chunked and sequential
reads share one assumption.
-}
planColumn :: ColumnEnv -> T.Text -> Int -> ColumnPlan
planColumn env name col = case mode of
    EitherRead -> PlanLegacy mode (isJust schemaEntry)
    _ -> case schemaEntry of
        Nothing -> chainPlan env mode col
        Just st -> case supportedType st of
            Just t -> PlanSchema mode (nullSpecFor opts mode) t
            Nothing -> PlanLegacy mode True
  where
    opts = ceOpts env
    mode = effectiveSafeRead (safeRead opts) (safeReadOverrides opts) name
    schemaEntry = M.lookup name (schemaTypeMap (typeSpec opts))

-- | Classify a row sample with the existing assumption logic.
chainPlan :: ColumnEnv -> SafeReadMode -> Int -> ColumnPlan
chainPlan env mode col =
    PlanChain mode (nullSpecFor opts mode) (chainFor assumption) noAssumption
  where
    opts = ceOpts env
    ts = typeSpec opts
    sampleN =
        if shouldInferFromSample ts then typeInferenceSampleSize ts else 0
    isNullT = case mode of
        NoSafeRead -> T.null
        _ -> isNullishOrMissing (missingIndicators opts)
    sample =
        V.generate
            (min sampleN (ceNumRow env))
            (\i -> extractField (ceCtx env) (rowAt env i) col)
    classified =
        V.map (\t -> if isNullT t then Nothing else Just t) sample
    assumption = makeParsingAssumption (dateFormat opts) classified
    noAssumption = case assumption of
        NoAssumption -> True
        _ -> False

{- | What a chain plan produced for one row range: every cell null (only
reported when the all-null pre-check is on), or the index of the first
chain step whose pass succeeded plus its column.
-}
data ChunkOutcome = AllNullChunk | Resolved !Int !PassCol

-- | Run a fallback chain over the env's row range.
runChainChunk ::
    ColumnEnv -> NullSpec -> [Step] -> Bool -> Int -> IO ChunkOutcome
runChainChunk env nspec steps checkAllNull col = do
    allNull <- if checkAllNull then allNullPass env nspec col else pure False
    if allNull then pure AllNullChunk else go 0 steps
  where
    go i (s : rest) = do
        r <- runStep env nspec col False s
        case r of
            Right c -> pure (Resolved i c)
            Left _ -> go (i + 1) rest
    go _ [] = error "fastcsv: type-fallback chain exhausted (StepText must succeed)"

{- | Run a schema-typed pass over the env's row range. @rowOff@ maps a
chunk-local failure index to the global data-row index.
-}
runSchemaChunk ::
    ColumnEnv ->
    NullSpec ->
    SafeReadMode ->
    TargetType ->
    Int ->
    Int ->
    IO (Either Int PassCol)
runSchemaChunk env nspec mode t col rowOff = do
    r <- runStep env nspec col (mode == MaybeRead) (stepForTarget t)
    pure (either (Left . (+ rowOff)) Right r)

-- | The error a schema column raises when a cell fails under 'NoSafeRead'.
schemaError :: T.Text -> TargetType -> Int -> ErrorCall
schemaError name t i =
    ErrorCall $
        "fastcsv: schema column "
            <> show name
            <> ": row "
            <> show i
            <> " does not parse as "
            <> targetName t

-- | The all-null column an all-null range resolves to (Maybe Text).
allNullColumn :: Int -> Column
allNullColumn n = fromVector (V.replicate n (Nothing :: Maybe T.Text))

-- | 'MaybeRead' columns keep their Maybe-ness even when fully valid.
finishMode :: SafeReadMode -> Column -> Column
finishMode MaybeRead = ensureOptional
finishMode _ = id

{- | Build one column sequentially (single chunk over the full row range).
Returns the column plus a flag telling the caller to re-apply the legacy
schema conversion ('parseWithTypes') afterwards — only set for schema'd
columns the typed passes cannot serve.
-}
buildColumn :: ColumnEnv -> T.Text -> Int -> IO (Column, Bool)
buildColumn env name col = case planColumn env name col of
    PlanLegacy mode pwt -> (,pwt) <$> legacyColumn env mode col
    PlanSchema mode nspec t -> do
        r <- runSchemaChunk env nspec mode t col 0
        c <- either (throwIO . schemaError name t) (pure . passColumn) r
        pure (finishMode mode c, False)
    PlanChain mode nspec steps checkAllNull -> do
        oc <- runChainChunk env nspec steps checkAllNull col
        let c = case oc of
                AllNullChunk -> allNullColumn (ceNumRow env)
                Resolved _ c' -> passColumn c'
        pure (finishMode mode c, False)

{- | The original Text-materializing pipeline, kept for 'EitherRead' and
exotic schema types (cold paths).
-}
legacyColumn :: ColumnEnv -> SafeReadMode -> Int -> IO Column
legacyColumn env mode col = do
    let opts = ceOpts env
        ts = typeSpec opts
        parseOpts =
            ParseOptions
                { missingValues = missingIndicators opts
                , sampleSize =
                    if shouldInferFromSample ts
                        then typeInferenceSampleSize ts
                        else 0
                , parseSafe = mode
                , parseSafeOverrides = []
                , parseDateFormat = dateFormat opts
                }
        cells =
            V.generate
                (ceNumRow env)
                (\i -> extractField (ceCtx env) (rowAt env i) col)
    pure (parseFromExamples parseOpts cells)