packages feed

dataframe-1.1.2.0: src/DataFrame/Operations/Typing.hs

{-# LANGUAGE AllowAmbiguousTypes #-}
{-# LANGUAGE BangPatterns #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE GADTs #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TypeApplications #-}

module DataFrame.Operations.Typing where

import qualified Data.Map as M
import qualified Data.Text as T
import qualified Data.Vector as V
import qualified Data.Vector.Mutable as VM
import qualified Data.Vector.Unboxed as VU
import qualified Data.Vector.Unboxed.Mutable as VUM

import Control.Applicative (asum)
import Control.Monad (join)
import Control.Monad.ST (runST)
import Data.Maybe (fromMaybe)
import qualified Data.Proxy as P
import Data.Time
import Data.Type.Equality (TestEquality (..))
import DataFrame.Internal.Column (
    Bitmap,
    Column (..),
    Columnable,
    bitmapTestBit,
    ensureOptional,
    finalizeParseResult,
    fromVector,
 )
import DataFrame.Internal.DataFrame (DataFrame (..), unsafeGetColumn)
import DataFrame.Internal.Parsing
import DataFrame.Internal.Schema
import DataFrame.Operations.Core
import Text.Read
import Type.Reflection

type DateFormat = String

{- | How parse failures are surfaced in the resulting column.

* 'NoSafeRead' — strict parsing: failures throw (via 'read').
* 'MaybeRead' — failures become 'Nothing'; columns are wrapped as @Maybe a@.
* 'EitherRead' — failures become @Left rawText@; columns are wrapped as
  @Either Text a@, preserving the original input so callers can inspect it.
-}
data SafeReadMode
    = NoSafeRead
    | MaybeRead
    | EitherRead
    deriving (Eq, Show, Read)

-- | Options controlling how text columns are parsed into typed values.
data ParseOptions = ParseOptions
    { missingValues :: [T.Text]
    -- ^ Values to treat as @Nothing@ when the effective mode is 'MaybeRead'.
    , sampleSize :: Int
    -- ^ Number of rows to inspect when inferring a column's type (0 = all rows).
    , parseSafe :: SafeReadMode
    {- ^ Default 'SafeReadMode' applied to every column that does not have an
    entry in 'parseSafeOverrides'. 'NoSafeRead' only treats empty strings as
    missing; 'MaybeRead' additionally treats 'missingValues' and nullish
    strings as @Nothing@; 'EitherRead' wraps the resulting column as
    @Either Text a@ with the raw input preserved on failure.
    -}
    , parseSafeOverrides :: [(T.Text, SafeReadMode)]
    {- ^ Per-column overrides. When a column name is present here, its value
    takes precedence over 'parseSafe'. Typical use: strict IDs
    (@NoSafeRead@) alongside lenient fields (@MaybeRead@/@EitherRead@).
    -}
    , parseDateFormat :: DateFormat
    -- ^ Date format string as accepted by "Data.Time.Format" (e.g. @\"%Y-%m-%d\"@).
    }

{- | Sensible out-of-the-box parse options: infer from the first 100 rows,
  treat common nullish strings as missing, and expect ISO 8601 dates.
-}
defaultParseOptions :: ParseOptions
defaultParseOptions =
    ParseOptions
        { missingValues = []
        , sampleSize = 100
        , parseSafe = MaybeRead
        , parseSafeOverrides = []
        , parseDateFormat = "%Y-%m-%d"
        }

{- | Resolve a column's effective 'SafeReadMode': the override if present,
otherwise the default.
-}
effectiveSafeRead ::
    SafeReadMode -> [(T.Text, SafeReadMode)] -> T.Text -> SafeReadMode
effectiveSafeRead def overrides name = fromMaybe def (lookup name overrides)

parseDefaults :: ParseOptions -> DataFrame -> DataFrame
parseDefaults opts df = df{columns = V.imap forCol (columns df)}
  where
    -- Index -> column name: reverse the columnIndices map once.
    nameAt =
        let inverted = M.fromList [(i, n) | (n, i) <- M.toList (columnIndices df)]
         in \i -> M.findWithDefault "" i inverted
    forCol i col =
        let mode =
                effectiveSafeRead
                    (parseSafe opts)
                    (parseSafeOverrides opts)
                    (nameAt i)
         in parseDefault opts{parseSafe = mode, parseSafeOverrides = []} col

parseDefault :: ParseOptions -> Column -> Column
parseDefault opts (BoxedColumn Nothing (c :: V.Vector a)) =
    case (typeRep @a) `testEquality` (typeRep @T.Text) of
        Nothing -> case (typeRep @a) `testEquality` (typeRep @String) of
            Just Refl -> parseFromExamples opts (V.map T.pack c)
            Nothing -> BoxedColumn Nothing c
        Just Refl -> parseFromExamples opts c
parseDefault opts (BoxedColumn (Just bm) (c :: V.Vector a)) =
    case (typeRep @a) `testEquality` (typeRep @T.Text) of
        Nothing -> case (typeRep @a) `testEquality` (typeRep @String) of
            Just Refl ->
                parseFromExamples
                    opts
                    (V.imap (\i x -> if bitmapTestBit bm i then T.pack x else "") c)
            Nothing -> BoxedColumn (Just bm) c
        Just Refl ->
            parseFromExamples opts (V.imap (\i x -> if bitmapTestBit bm i then x else "") c)
parseDefault _ column = column

parseFromExamples :: ParseOptions -> V.Vector T.Text -> Column
parseFromExamples opts cols =
    let isNull = case parseSafe opts of
            NoSafeRead -> T.null
            _ -> isNullishOrMissing (missingValues opts)
        -- `examples` is small (≤ sampleSize, default 100), so the
        -- Maybe-wrap allocation here is ignorable.  The full-column
        -- equivalent (`asMaybeText = V.map ... cols`) has been removed:
        -- handlers now walk `cols` directly with `isNull`.
        examples = V.map (classify isNull) (V.take (sampleSize opts) cols)
        dfmt = parseDateFormat opts
        assumption = makeParsingAssumption dfmt examples
     in case parseSafe opts of
            EitherRead -> handleEitherAssumption dfmt assumption cols
            mode ->
                let result = case assumption of
                        BoolAssumption -> handleBoolAssumption isNull cols
                        IntAssumption -> handleIntAssumption isNull cols
                        DoubleAssumption -> handleDoubleAssumption isNull cols
                        TextAssumption -> handleTextAssumption isNull cols
                        DateAssumption -> handleDateAssumption dfmt isNull cols
                        NoAssumption -> handleNoAssumption dfmt isNull cols
                 in if mode == MaybeRead then ensureOptional result else result
  where
    classify p t = if p t then Nothing else Just t

{- | For 'EitherRead' mode: take the chosen parsing assumption and produce an
@Either Text a@ column. Successful parses become @Right@; any row that fails
to parse as the chosen type (including null/missing cells) becomes @Left@
carrying the raw input text verbatim.
-}
handleEitherAssumption ::
    DateFormat -> ParsingAssumption -> V.Vector T.Text -> Column
handleEitherAssumption dfmt assumption raw = case assumption of
    BoolAssumption -> fromVector (V.map (toEither readBool) raw)
    IntAssumption -> fromVector (V.map (toEither readInt) raw)
    DoubleAssumption -> fromVector (V.map (toEither readDouble) raw)
    DateAssumption -> fromVector (V.map (toEither (parseTimeOpt dfmt)) raw)
    -- TextAssumption and NoAssumption degenerate to Either Text Text; treat
    -- empty strings as Left "" so the convention (Left = missing/failure) stays
    -- consistent across column types.
    TextAssumption -> fromVector (V.map textToEither raw)
    NoAssumption -> fromVector (V.map textToEither raw)
  where
    toEither :: (T.Text -> Maybe a) -> T.Text -> Either T.Text a
    toEither p t = maybe (Left t) Right (p t)

    textToEither :: T.Text -> Either T.Text T.Text
    textToEither t = if T.null t then Left t else Right t

parseUnboxedColumnWithPred ::
    forall src a.
    (VU.Unbox a) =>
    a ->
    (src -> Bool) ->
    (src -> Maybe a) ->
    V.Vector src ->
    Maybe (Maybe Bitmap, VU.Vector a)
parseUnboxedColumnWithPred nullValue isNull parser vec = runST $ do
    let n = V.length vec
    values <- VUM.unsafeNew n
    vmask <- VUM.unsafeNew n
    let go !i !anyNull
            | i >= n = finalizeParseResult values vmask anyNull
            | otherwise =
                let !src = V.unsafeIndex vec i
                 in if isNull src
                        then do
                            VUM.unsafeWrite vmask i 0
                            VUM.unsafeWrite values i nullValue
                            go (i + 1) True
                        else case parser src of
                            Just v -> do
                                VUM.unsafeWrite vmask i 1
                                VUM.unsafeWrite values i v
                                go (i + 1) anyNull
                            Nothing -> return Nothing
    go 0 False
{-# INLINE parseUnboxedColumnWithPred #-}

-- | Wrap a successful 'parseUnboxedColumnWithPred' result as a 'Column'.
unboxedOrFallback ::
    (Columnable a, VU.Unbox a) =>
    Maybe (Maybe Bitmap, VU.Vector a) ->
    Column ->
    Column
unboxedOrFallback (Just (mbm, vec)) _ = UnboxedColumn mbm vec
unboxedOrFallback Nothing fallback = fallback

handleBoolAssumption :: (T.Text -> Bool) -> V.Vector T.Text -> Column
handleBoolAssumption isNull cols =
    unboxedOrFallback
        (parseUnboxedColumnWithPred False isNull readBool cols)
        (handleTextAssumption isNull cols)

handleIntAssumption :: (T.Text -> Bool) -> V.Vector T.Text -> Column
handleIntAssumption isNull cols =
    case parseUnboxedColumnWithPred 0 isNull readInt cols of
        Just (mbm, vec) -> UnboxedColumn mbm vec
        Nothing ->
            unboxedOrFallback
                (parseUnboxedColumnWithPred 0 isNull readDouble cols)
                (handleTextAssumption isNull cols)

handleDoubleAssumption :: (T.Text -> Bool) -> V.Vector T.Text -> Column
handleDoubleAssumption isNull cols =
    unboxedOrFallback
        (parseUnboxedColumnWithPred 0 isNull readDouble cols)
        (handleTextAssumption isNull cols)

{- | Text columns: no parse, just null-marking.  When the whole column
is non-null we return a plain 'V.Vector T.Text'; otherwise we emit a
@V.Vector (Maybe T.Text)@ the same shape the old code produced.
-}
handleTextAssumption :: (T.Text -> Bool) -> V.Vector T.Text -> Column
handleTextAssumption isNull cols
    | V.any isNull cols =
        fromVector
            (V.map (\t -> if isNull t then Nothing else Just t) cols)
    | otherwise = fromVector cols

{- | Date: single parse pass, boxed because 'Day' is not unboxable.
Bails to 'handleTextAssumption' the moment a non-null cell fails to
parse as a 'Day'.  Still avoids the outer @V.Vector (Maybe T.Text)@
allocation — we walk @cols@ directly with @isNull@.
-}
handleDateAssumption ::
    DateFormat -> (T.Text -> Bool) -> V.Vector T.Text -> Column
handleDateAssumption dateFormat isNull cols =
    case parseBoxedMaybeColumn isNull (parseTimeOpt dateFormat) cols of
        Just (anyNull, vec)
            -- `vec :: V.Vector (Maybe Day)`.  If no nulls, strip the
            -- outer 'Maybe' (every cell is guaranteed 'Just') so the
            -- column type stays 'Day' rather than becoming 'Maybe Day'.
            | anyNull -> fromVector vec
            | otherwise -> fromVector (V.mapMaybe id vec)
        Nothing -> handleTextAssumption isNull cols

parseBoxedMaybeColumn ::
    (T.Text -> Bool) ->
    (T.Text -> Maybe a) ->
    V.Vector T.Text ->
    Maybe (Bool, V.Vector (Maybe a))
parseBoxedMaybeColumn isNull parser cols = runST $ do
    let n = V.length cols
    out <- VM.new n
    let loop !i !anyNull
            | i >= n = do
                frozen <- V.unsafeFreeze out
                return (Just (anyNull, frozen))
            | otherwise =
                let !t = V.unsafeIndex cols i
                 in if isNull t
                        then do
                            VM.unsafeWrite out i Nothing
                            loop (i + 1) True
                        else case parser t of
                            Just v -> do
                                VM.unsafeWrite out i (Just v)
                                loop (i + 1) anyNull
                            Nothing -> return Nothing
    loop 0 False

handleNoAssumption ::
    DateFormat -> (T.Text -> Bool) -> V.Vector T.Text -> Column
handleNoAssumption dateFormat isNull cols
    -- Only reached when the 100-row sample was all-null.  Try each
    -- concrete type in turn; fall back to Text otherwise.
    | V.all isNull cols =
        fromVector (V.map (const (Nothing :: Maybe T.Text)) cols)
    | Just (mbm, vec) <- parseUnboxedColumnWithPred False isNull readBool cols =
        UnboxedColumn mbm vec
    | Just (mbm, vec) <- parseUnboxedColumnWithPred 0 isNull readInt cols =
        UnboxedColumn mbm vec
    | Just (mbm, vec) <- parseUnboxedColumnWithPred 0 isNull readDouble cols =
        UnboxedColumn mbm vec
    | otherwise = case parseBoxedMaybeColumn isNull (parseTimeOpt dateFormat) cols of
        Just (anyNull, vec)
            -- `vec :: V.Vector (Maybe Day)`.  If no nulls, strip the
            -- outer 'Maybe' (every cell is guaranteed 'Just') so the
            -- column type stays 'Day' rather than becoming 'Maybe Day'.
            | anyNull -> fromVector vec
            | otherwise -> fromVector (V.mapMaybe id vec)
        Nothing -> handleTextAssumption isNull cols

{- | Predicate matching what 'parseSafe == NoSafeRead' previously used:
only empty strings are treated as missing.

We still expose 'convertNullish' \/ 'convertOnlyEmpty' below because
other parts of the library reference them, but neither is used by
'parseFromExamples' any longer.
-}
isNullishOrMissing :: [T.Text] -> T.Text -> Bool
isNullishOrMissing missing v = isNullish v || v `elem` missing

convertNullish :: [T.Text] -> T.Text -> Maybe T.Text
convertNullish missing v = if isNullish v || v `elem` missing then Nothing else Just v

convertOnlyEmpty :: T.Text -> Maybe T.Text
convertOnlyEmpty v = if v == "" then Nothing else Just v

parseTimeOpt :: DateFormat -> T.Text -> Maybe Day
parseTimeOpt dateFormat s =
    parseTimeM {- Accept leading/trailing whitespace -}
        True
        defaultTimeLocale
        dateFormat
        (T.unpack s)

unsafeParseTime :: DateFormat -> T.Text -> Day
unsafeParseTime dateFormat s =
    parseTimeOrError {- Accept leading/trailing whitespace -}
        True
        defaultTimeLocale
        dateFormat
        (T.unpack s)

hasNullValues :: (Eq a) => V.Vector (Maybe a) -> Bool
hasNullValues = V.any (== Nothing)

vecSameConstructor :: V.Vector (Maybe a) -> V.Vector (Maybe b) -> Bool
vecSameConstructor xs ys = (V.length xs == V.length ys) && V.and (V.zipWith hasSameConstructor xs ys)
  where
    hasSameConstructor :: Maybe a -> Maybe b -> Bool
    hasSameConstructor (Just _) (Just _) = True
    hasSameConstructor Nothing Nothing = True
    hasSameConstructor _ _ = False

makeParsingAssumption ::
    DateFormat -> V.Vector (Maybe T.Text) -> ParsingAssumption
makeParsingAssumption dateFormat asMaybeText
    -- All the examples are "NA", "Null", "", so we can't make any shortcut
    -- assumptions and just have to go the long way.
    | V.all (== Nothing) asMaybeText = NoAssumption
    -- After accounting for nulls, parsing for Ints and Doubles results in the
    -- same corresponding positions of Justs and Nothings, so we assume
    -- that the best way to parse is Int
    | vecSameConstructor asMaybeText asMaybeBool = BoolAssumption
    | vecSameConstructor asMaybeText asMaybeInt
        && vecSameConstructor asMaybeText asMaybeDouble =
        IntAssumption
    -- After accounting for nulls, the previous condition fails, so some (or none) can be parsed as Ints
    -- and some can be parsed as Doubles, so we make the assumpotion of doubles.
    | vecSameConstructor asMaybeText asMaybeDouble = DoubleAssumption
    -- After accounting for nulls, parsing for Dates results in the same corresponding
    -- positions of Justs and Nothings, so we assume that the best way to parse is Date.
    | vecSameConstructor asMaybeText asMaybeDate = DateAssumption
    | otherwise = TextAssumption
  where
    asMaybeBool = V.map (>>= readBool) asMaybeText
    asMaybeInt = V.map (>>= readInt) asMaybeText
    asMaybeDouble = V.map (>>= readDouble) asMaybeText
    asMaybeDate = V.map (>>= parseTimeOpt dateFormat) asMaybeText

data ParsingAssumption
    = BoolAssumption
    | IntAssumption
    | DoubleAssumption
    | DateAssumption
    | NoAssumption
    | TextAssumption

{- | Re-type columns of a 'DataFrame' according to the supplied schema map.
The caller provides a @resolveMode@ function that maps a column name to its
'SafeReadMode' — typically built from a global default plus an overrides map
via 'effectiveSafeRead'.
-}
parseWithTypes ::
    (T.Text -> SafeReadMode) ->
    M.Map T.Text SchemaType ->
    DataFrame ->
    DataFrame
parseWithTypes resolveMode ts df
    | M.null ts = df
    | otherwise =
        M.foldrWithKey
            (\k v d -> insertColumn k (asType (resolveMode k) v (unsafeGetColumn k d)) d)
            df
            ts
  where
    -- \| Re-parse a plain (non-Maybe, non-Either) target type according to the
    -- 'SafeReadMode'. @toStr@ converts column elements to a 'String' ready for
    -- 'Read'.
    plainType ::
        forall a b.
        (Columnable a, Read a) =>
        SafeReadMode -> V.Vector b -> (b -> String) -> Column
    plainType mode col toStr = case mode of
        NoSafeRead -> fromVector (V.map ((read @a) . toStr) col)
        MaybeRead -> fromVector (V.map ((readMaybe @a) . toStr) col)
        EitherRead -> fromVector (V.map ((readEitherRaw @a) . toStr) col)

    asType :: SafeReadMode -> SchemaType -> Column -> Column
    asType mode (SType (_ :: P.Proxy a)) c@(BoxedColumn _ (col :: V.Vector b)) = case typeRep @a of
        App t1 _t2 -> case eqTypeRep t1 (typeRep @Maybe) of
            Just HRefl -> case testEquality (typeRep @a) (typeRep @b) of
                Just Refl -> c
                Nothing -> case testEquality (typeRep @T.Text) (typeRep @b) of
                    Just Refl -> fromVector (V.map (join . (readAsMaybe @a) . T.unpack) col)
                    Nothing -> fromVector (V.map (join . (readAsMaybe @a) . show) col)
            Nothing -> case t1 of
                App t1' _t2' -> case eqTypeRep t1' (typeRep @Either) of
                    Just HRefl -> case testEquality (typeRep @a) (typeRep @b) of
                        Just Refl -> c
                        Nothing -> case testEquality (typeRep @T.Text) (typeRep @b) of
                            Just Refl -> fromVector (V.map ((readAsEither @a) . T.unpack) col)
                            Nothing -> fromVector (V.map ((readAsEither @a) . show) col)
                    Nothing -> case testEquality (typeRep @a) (typeRep @b) of
                        Just Refl -> c
                        Nothing -> case testEquality (typeRep @T.Text) (typeRep @b) of
                            Just Refl -> plainType @a mode col T.unpack
                            Nothing -> plainType @a mode col show
                _ -> c
        _ -> case testEquality (typeRep @a) (typeRep @b) of
            Just Refl -> c
            Nothing -> case testEquality (typeRep @T.Text) (typeRep @b) of
                Just Refl -> plainType @a mode col T.unpack
                Nothing -> plainType @a mode col show
    asType _ _ c = c

readAsMaybe :: (Read a) => String -> Maybe a
readAsMaybe s
    | null s = Nothing
    | otherwise = readMaybe $ "Just " <> s

readAsEither :: (Read a) => String -> a
readAsEither v = case asum [readMaybe $ "Left " <> s, readMaybe $ "Right " <> s] of
    Nothing -> error $ "Couldn't read value: " <> s
    Just v' -> v'
  where
    s = if null v then "\"\"" else v

{- | Try 'readMaybe'; on failure return @Left raw@ where @raw@ is the original
input text. Used by 'parseWithTypes' under 'EitherRead'.
-}
readEitherRaw :: forall a. (Read a) => String -> Either T.Text a
readEitherRaw s = case readMaybe s of
    Just v -> Right v
    Nothing -> Left (T.pack s)