dataframe-parsing 1.0.2.1 → 2.1.1.0
raw patch · 17 files changed
Files
- dataframe-parsing.cabal +16/−14
- src-internal/DataFrame/Internal/Binary.hs +94/−0
- src-internal/DataFrame/Internal/Parsing.hs +238/−0
- src-internal/DataFrame/Internal/Parsing/Fast.hs +100/−0
- src-internal/DataFrame/Internal/Parsing/Fast/Common.hs +103/−0
- src-internal/DataFrame/Internal/Parsing/Fast/Double.hs +124/−0
- src-internal/DataFrame/Internal/Parsing/Fast/Int.hs +52/−0
- src-internal/DataFrame/Internal/Parsing/Fast/Token.hs +110/−0
- src/DataFrame/Internal/Binary.hs +0/−94
- src/DataFrame/Internal/Parsing.hs +0/−238
- src/DataFrame/Internal/Parsing/Fast.hs +0/−107
- src/DataFrame/Internal/Parsing/Fast/Common.hs +0/−108
- src/DataFrame/Internal/Parsing/Fast/Double.hs +0/−133
- src/DataFrame/Internal/Parsing/Fast/Int.hs +0/−53
- src/DataFrame/Internal/Parsing/Fast/Token.hs +0/−110
- src/DataFrame/Internal/Schema.hs +37/−0
- src/DataFrame/Schema.hs +9/−0
dataframe-parsing.cabal view
@@ -1,6 +1,6 @@-cabal-version: 2.4+cabal-version: 3.4 name: dataframe-parsing-version: 1.0.2.1+version: 2.1.1.0 synopsis: Shared text/binary parsing helpers for the dataframe ecosystem. description: Parsing primitives used by the @dataframe@ family: CSV-friendly text@@ -30,24 +30,26 @@ library import: warnings exposed-modules:+ DataFrame.Schema DataFrame.Internal.Binary DataFrame.Internal.Parsing DataFrame.Internal.Parsing.Fast+ other-modules:+ DataFrame.Internal.Schema DataFrame.Internal.Parsing.Fast.Common DataFrame.Internal.Parsing.Fast.Double DataFrame.Internal.Parsing.Fast.Int DataFrame.Internal.Parsing.Fast.Token- DataFrame.Internal.Schema build-depends: base >= 4 && < 5,- attoparsec >= 0.12 && < 0.15,- bytestring >= 0.11 && < 0.13,- bytestring-lexing >= 0.5 && < 0.6,- containers >= 0.6.7 && < 0.9,- dataframe-core ^>= 1.1,+ attoparsec >= 0.12 && < 0.16,+ bytestring >= 0.11 && < 0.14,+ bytestring-lexing >= 0.5 && < 0.7,+ containers >= 0.6.7 && < 0.10,+ dataframe-core >= 2.1 && < 2.2, text >= 2.1 && < 3, time >= 1.12 && < 2,- vector >= 0.12 && < 0.14- hs-source-dirs: src+ vector >= 0.12 && < 0.15+ hs-source-dirs: src, src-internal default-language: Haskell2010 test-suite tests@@ -57,9 +59,9 @@ other-modules: Properties.FastParsing Unit.FastParsing build-depends: base >= 4 && < 5,- bytestring >= 0.11 && < 0.13,+ bytestring >= 0.11 && < 0.14, dataframe-parsing,- HUnit ^>= 1.6,+ HUnit >= 1.6 && < 1.8, QuickCheck >= 2 && < 3, text >= 2.1 && < 3 hs-source-dirs: tests@@ -71,11 +73,11 @@ type: exitcode-stdio-1.0 main-is: FieldParsers.hs build-depends: base >= 4 && < 5,- bytestring >= 0.11 && < 0.13,+ bytestring >= 0.11 && < 0.14, dataframe-parsing, text >= 2.1 && < 3, time >= 1.12 && < 2,- vector >= 0.12 && < 0.14+ vector >= 0.12 && < 0.15 hs-source-dirs: bench ghc-options: -O2 default-language: Haskell2010
+ src-internal/DataFrame/Internal/Binary.hs view
@@ -0,0 +1,94 @@+{-# LANGUAGE BangPatterns #-}++module DataFrame.Internal.Binary where++import Data.Bits (Bits (unsafeShiftL, (.|.)))+import Data.ByteString (toStrict)+import qualified Data.ByteString as BS+import Data.ByteString.Builder (toLazyByteString, word32LE, word64LE)+import qualified Data.ByteString.Unsafe as BS+import Data.Int (Int32)+import Data.Word (Word32, Word64, Word8)++littleEndianWord32 :: BS.ByteString -> Word32+littleEndianWord32 bytes+ | len >= 4 =+ assembleWord32+ (BS.unsafeIndex bytes 0)+ (BS.unsafeIndex bytes 1)+ (BS.unsafeIndex bytes 2)+ (BS.unsafeIndex bytes 3)+ | otherwise =+ assembleWord32+ (byteAtOrZero len bytes 0)+ (byteAtOrZero len bytes 1)+ (byteAtOrZero len bytes 2)+ (byteAtOrZero len bytes 3)+ where+ len = BS.length bytes+{-# INLINE littleEndianWord32 #-}++littleEndianWord64 :: BS.ByteString -> Word64+littleEndianWord64 bytes+ | len >= 8 =+ assembleWord64+ (BS.index bytes 0)+ (BS.index bytes 1)+ (BS.index bytes 2)+ (BS.index bytes 3)+ (BS.index bytes 4)+ (BS.index bytes 5)+ (BS.index bytes 6)+ (BS.index bytes 7)+ | otherwise =+ assembleWord64+ (byteAtOrZero len bytes 0)+ (byteAtOrZero len bytes 1)+ (byteAtOrZero len bytes 2)+ (byteAtOrZero len bytes 3)+ (byteAtOrZero len bytes 4)+ (byteAtOrZero len bytes 5)+ (byteAtOrZero len bytes 6)+ (byteAtOrZero len bytes 7)+ where+ len = BS.length bytes+{-# INLINE littleEndianWord64 #-}++littleEndianInt32 :: BS.ByteString -> Int32+littleEndianInt32 = fromIntegral . littleEndianWord32+{-# INLINE littleEndianInt32 #-}++word64ToLittleEndian :: Word64 -> BS.ByteString+word64ToLittleEndian = toStrict . toLazyByteString . word64LE+{-# INLINE word64ToLittleEndian #-}++word32ToLittleEndian :: Word32 -> BS.ByteString+word32ToLittleEndian = toStrict . toLazyByteString . word32LE+{-# INLINE word32ToLittleEndian #-}++byteAtOrZero :: Int -> BS.ByteString -> Int -> Word8+byteAtOrZero len bytes i+ | i >= 0 && i < len = BS.unsafeIndex bytes i+ | otherwise = 0+{-# INLINE byteAtOrZero #-}++assembleWord32 :: Word8 -> Word8 -> Word8 -> Word8 -> Word32+assembleWord32 !b0 !b1 !b2 !b3 =+ fromIntegral b0+ .|. (fromIntegral b1 `unsafeShiftL` 8)+ .|. (fromIntegral b2 `unsafeShiftL` 16)+ .|. (fromIntegral b3 `unsafeShiftL` 24)+{-# INLINE assembleWord32 #-}++assembleWord64 ::+ Word8 -> Word8 -> Word8 -> Word8 -> Word8 -> Word8 -> Word8 -> Word8 -> Word64+assembleWord64 !b0 !b1 !b2 !b3 !b4 !b5 !b6 !b7 =+ fromIntegral b0+ .|. (fromIntegral b1 `unsafeShiftL` 8)+ .|. (fromIntegral b2 `unsafeShiftL` 16)+ .|. (fromIntegral b3 `unsafeShiftL` 24)+ .|. (fromIntegral b4 `unsafeShiftL` 32)+ .|. (fromIntegral b5 `unsafeShiftL` 40)+ .|. (fromIntegral b6 `unsafeShiftL` 48)+ .|. (fromIntegral b7 `unsafeShiftL` 56)+{-# INLINE assembleWord64 #-}
+ src-internal/DataFrame/Internal/Parsing.hs view
@@ -0,0 +1,238 @@+{-# LANGUAGE CPP #-}+{-# LANGUAGE LambdaCase #-}+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE ScopedTypeVariables #-}++module DataFrame.Internal.Parsing where++import qualified Data.ByteString.Char8 as C+import qualified Data.Set as S+import qualified Data.Text as T+import qualified Data.Text.IO as TIO++import Control.Applicative (many, (<|>))+import Data.Attoparsec.Text hiding (decimal, double, signed)+import Data.ByteString.Lex.Fractional+import Data.Foldable (fold)+import Data.Text.Read (decimal, double, signed)+import Data.Time (Day, defaultTimeLocale, parseTimeM)+import GHC.Stack (HasCallStack)+import System.IO (Handle, IOMode (..), hIsEOF, hTell, withFile)+import Prelude hiding (takeWhile)++isNullish :: T.Text -> Bool+isNullish =+ ( `S.member`+ S.fromList+ ["Nothing", "NULL", "", " ", "nan", "null", "N/A", "NaN", "NAN", "NA"]+ )++isNullishBS :: C.ByteString -> Bool+isNullishBS =+ ( `S.member`+ S.fromList+ ["Nothing", "NULL", "", " ", "nan", "null", "N/A", "NaN", "NAN", "NA"]+ )++isTrueish :: T.Text -> Bool+isTrueish t = t `elem` ["True", "true", "TRUE"]++isFalseish :: T.Text -> Bool+isFalseish t = t `elem` ["False", "false", "FALSE"]++readBool :: (HasCallStack) => T.Text -> Maybe Bool+readBool s+ | isTrueish s = Just True+ | isFalseish s = Just False+ | otherwise = Nothing++readByteStringBool :: C.ByteString -> Maybe Bool+readByteStringBool s+ | s `elem` ["True", "true", "TRUE"] = Just True+ | s `elem` ["False", "false", "FALSE"] = Just False+ | otherwise = Nothing++readByteStringDate :: String -> C.ByteString -> Maybe Day+readByteStringDate fmt = parseTimeM True defaultTimeLocale fmt . C.unpack++readInteger :: (HasCallStack) => T.Text -> Maybe Integer+readInteger s = case signed decimal (T.strip s) of+ Left _ -> Nothing+ Right (value, "") -> Just value+ Right (_value, _) -> Nothing++readInt :: (HasCallStack) => T.Text -> Maybe Int+readInt s = case signed decimal (T.strip s) of+ Left _ -> Nothing+ Right (value, "") -> Just value+ Right (_value, _) -> Nothing+{-# INLINE readInt #-}++readByteStringInt :: (HasCallStack) => C.ByteString -> Maybe Int+#if MIN_VERSION_bytestring(0,12,0)+-- bytestring >= 0.12: 'C.readInt' returns 'Nothing' on overflow.+readByteStringInt s = case C.readInt (C.strip s) of+ Just (value, "") -> Just value+ _ -> Nothing+#else+-- bytestring < 0.12: 'C.readInt' silently wraps on overflow. Fields of+-- <= 18 characters fit in an 'Int' and keep the fast path; longer ones fall+-- back to arbitrary-precision 'C.readInteger' with a range check.+readByteStringInt s+ | C.length t <= 18 = case C.readInt t of+ Just (value, "") -> Just value+ _ -> Nothing+ | otherwise = case C.readInteger t of+ Just (value, "")+ | value >= toInteger (minBound :: Int)+ , value <= toInteger (maxBound :: Int) ->+ Just (fromInteger value)+ _ -> Nothing+ where+ t = C.strip s+#endif+{-# INLINE readByteStringInt #-}++readByteStringDouble :: (HasCallStack) => C.ByteString -> Maybe Double+readByteStringDouble s =+ let+ readFunc = if C.any (\c -> c == 'e' || c == 'E') s then readExponential else readDecimal+ in+ case readSigned readFunc (C.strip s) of+ Nothing -> Nothing+ Just (value, "") -> Just value+ Just (_value, _) -> Nothing+{-# INLINE readByteStringDouble #-}++readDouble :: (HasCallStack) => T.Text -> Maybe Double+readDouble s =+ case signed double s of+ Left _ -> Nothing+ Right (value, "") -> Just value+ Right (_value, _) -> Nothing+{-# INLINE readDouble #-}++readIntegerEither :: (HasCallStack) => T.Text -> Either T.Text Integer+readIntegerEither s = case signed decimal (T.strip s) of+ Left _ -> Left s+ Right (value, "") -> Right value+ Right (_value, _) -> Left s+{-# INLINE readIntegerEither #-}++readIntEither :: (HasCallStack) => T.Text -> Either T.Text Int+readIntEither s = case signed decimal (T.strip s) of+ Left _ -> Left s+ Right (value, "") -> Right value+ Right (_value, _) -> Left s+{-# INLINE readIntEither #-}++readDoubleEither :: (HasCallStack) => T.Text -> Either T.Text Double+readDoubleEither s =+ case signed double s of+ Left _ -> Left s+ Right (value, "") -> Right value+ Right (_value, _) -> Left s+{-# INLINE readDoubleEither #-}++-- ---------------------------------------------------------------------------+-- Attoparsec CSV parser combinators (shared between Lazy.IO.CSV and others)+-- ---------------------------------------------------------------------------++parseSep :: Char -> T.Text -> [T.Text]+parseSep c s = either error id (parseOnly (record c) s)+{-# INLINE parseSep #-}++record :: Char -> Parser [T.Text]+record c =+ field c `sepBy1` char c+ <?> "record"+{-# INLINE record #-}++parseRow :: Char -> Parser [T.Text]+parseRow c = (record c <* lineEnd) <?> "record-new-line"++field :: Char -> Parser T.Text+field c =+ quotedField <|> unquotedField c+ <?> "field"+{-# INLINE field #-}++unquotedTerminators :: Char -> S.Set Char+unquotedTerminators sep = S.fromList [sep, '\n', '\r', '"']++unquotedField :: Char -> Parser T.Text+unquotedField sep =+ takeWhile (not . (`S.member` terminators)) <?> "unquoted field"+ where+ terminators = unquotedTerminators sep+{-# INLINE unquotedField #-}++quotedField :: Parser T.Text+quotedField = char '"' *> contents <* char '"' <?> "quoted field"+ where+ contents = fold <$> many (unquote <|> unescape)+ where+ unquote = takeWhile1 (notInClass "\"\\")+ unescape =+ char '\\' *> do+ T.singleton <$> do+ char '\\' <|> char '"'+{-# INLINE quotedField #-}++lineEnd :: Parser ()+lineEnd =+ (endOfLine <|> endOfInput)+ <?> "end of line"+{-# INLINE lineEnd #-}++-- | First pass to count rows for exact allocation.+countRows :: Char -> FilePath -> IO Int+countRows c path = withFile path ReadMode $! go 0 ""+ where+ go n input h = do+ isEOF <- hIsEOF h+ if isEOF && input == mempty+ then pure n+ else+ parseWith (TIO.hGetChunk h) (parseRow c) input >>= \case+ Fail unconsumed ctx er -> do+ erpos <- hTell h+ fail $+ "Failed to parse CSV file around "+ <> show erpos+ <> " byte; due: "+ <> show er+ <> "; context: "+ <> show ctx+ <> " "+ <> show unconsumed+ Partial _ -> fail $ "Partial handler is called; n = " <> show n+ Done (unconsumed :: T.Text) _ ->+ go (n + 1) unconsumed h+{-# INLINE countRows #-}++-- | Infer the Haskell type name from a text sample.+inferValueType :: T.Text -> T.Text+inferValueType s = case readInt s of+ Just _ -> "Int"+ Nothing -> case readDouble s of+ Just _ -> "Double"+ Nothing -> "Other"+{-# INLINE inferValueType #-}++-- | Read a single CSV row from a handle using the given separator.+readSingleLine :: Char -> T.Text -> Handle -> IO ([T.Text], T.Text)+readSingleLine c unused handle =+ parseWith (TIO.hGetChunk handle) (parseRow c) unused >>= \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 _ -> fail "Partial handler is called"+ Done (unconsumed :: T.Text) (row :: [T.Text]) ->+ return (row, unconsumed)
+ src-internal/DataFrame/Internal/Parsing/Fast.hs view
@@ -0,0 +1,100 @@+{-# LANGUAGE MagicHash #-}+{-# LANGUAGE UnboxedTuples #-}++{- | Fast, non-backtracking field parsers for CSV ingest. Each is a pure+function over a @(buf, start, end)@ byte slice, bit-exact with the reference+parsers in "DataFrame.Internal.Parsing". Unboxed @#@ variants avoid boxing.+-}+module DataFrame.Internal.Parsing.Fast (+ -- * Int fields+ parseIntField,+ parseIntFieldSlice,+ parseIntField#,++ -- * Double fields+ parseDoubleField,+ parseDoubleFieldSlice,+ parseDoubleField#,++ -- * Bool fields+ parseBoolField,+ parseBoolFieldSlice,+ parseBoolField#,++ -- * Date fields (default @%Y-%m-%d@ format)+ parseDateField,+ parseDateFieldSlice,++ -- * Missing-token test+ isMissingField,+ isMissingFieldSlice,+ isMissingFieldIn,+) where++import qualified Data.ByteString as BS++import Data.Time (Day)+import GHC.Exts (Double (..), Int (..))++import DataFrame.Internal.Parsing.Fast.Double (parseDoubleField#)+import DataFrame.Internal.Parsing.Fast.Int (parseIntField#)+import DataFrame.Internal.Parsing.Fast.Token (+ isMissingFieldIn,+ isMissingFieldSlice,+ parseBoolField#,+ parseDateFieldSlice,+ )++{- | Strip-tolerant @Int@ parse of a whole field; rejects overflow,+matching @readByteStringInt@ exactly.+-}+parseIntField :: BS.ByteString -> Maybe Int+parseIntField bs = parseIntFieldSlice bs 0 (BS.length bs)+{-# INLINE parseIntField #-}++parseIntFieldSlice :: BS.ByteString -> Int -> Int -> Maybe Int+parseIntFieldSlice bs start end =+ case parseIntField# bs start end of+ (# 0#, _ #) -> Nothing+ (# _, n #) -> Just (I# n)+{-# INLINE parseIntFieldSlice #-}++{- | Strip-tolerant @Double@ parse of a whole field; bit-exact with+@readByteStringDouble@ (falls back to it outside the fast window).+-}+parseDoubleField :: BS.ByteString -> Maybe Double+parseDoubleField bs = parseDoubleFieldSlice bs 0 (BS.length bs)+{-# INLINE parseDoubleField #-}++parseDoubleFieldSlice :: BS.ByteString -> Int -> Int -> Maybe Double+parseDoubleFieldSlice bs start end =+ case parseDoubleField# bs start end of+ (# 0#, _ #) -> Nothing+ (# _, d #) -> Just (D# d)+{-# INLINE parseDoubleFieldSlice #-}++-- | Exact-match Bool parse (@True|true|TRUE|False|false|FALSE@, no strip).+parseBoolField :: BS.ByteString -> Maybe Bool+parseBoolField bs = parseBoolFieldSlice bs 0 (BS.length bs)+{-# INLINE parseBoolField #-}++parseBoolFieldSlice :: BS.ByteString -> Int -> Int -> Maybe Bool+parseBoolFieldSlice bs start end =+ case parseBoolField# bs start end of+ (# 0#, _ #) -> Nothing+ (# _, b #) -> Just (I# b /= 0)+{-# INLINE parseBoolFieldSlice #-}++{- | @%Y-%m-%d@ date parse: byte-level fast path for the padded+10-byte shape, 'Data.Time.parseTimeM' fallback for everything else.+-}+parseDateField :: BS.ByteString -> Maybe Day+parseDateField bs = parseDateFieldSlice bs 0 (BS.length bs)+{-# INLINE parseDateField #-}++{- | Byte-level test against the canonical missing-token list+(@Nothing NULL \"\" \" \" nan null N\/A NaN NAN NA@), no Text decode.+-}+isMissingField :: BS.ByteString -> Bool+isMissingField bs = isMissingFieldSlice bs 0 (BS.length bs)+{-# INLINE isMissingField #-}
+ src-internal/DataFrame/Internal/Parsing/Fast/Common.hs view
@@ -0,0 +1,103 @@+{-# LANGUAGE BangPatterns #-}+{-# LANGUAGE MagicHash #-}+{-# LANGUAGE PolyKinds #-}+{-# LANGUAGE RankNTypes #-}+{-# LANGUAGE ScopedTypeVariables #-}++{- | Shared byte-level helpers for the fast slice parsers. All operate on+@(buf, start, end)@ slices; the caller guarantees+@0 <= start <= end <= length buf@ so everything below uses 'unsafeIndex'.+-}+module DataFrame.Internal.Parsing.Fast.Common (+ isStripByte,+ isDigitByte,+ skipStrip,+ skipStripEnd,+ skipZeroes,+ takeDigits64,+) where++import qualified Data.ByteString as BS+import qualified Data.ByteString.Unsafe as BSU++import Data.Word (Word64, Word8)+import GHC.Exts (+ Int (..),+ Int#,+ RuntimeRep,+ TYPE,+ Word64#,+ isTrue#,+ plusWord64#,+ timesWord64#,+ wordToWord64#,+ (+#),+ (>=#),+ )+import GHC.Word (Word64 (..))++{- | Bytes removed by 'Data.ByteString.Char8.strip' (Latin-1 'isSpace'):+HT LF VT FF CR SP and NBSP 0xA0. Probed over all 256 bytes; parity+with the strip-based reference parsers depends on this exact set.+-}+isStripByte :: Word8 -> Bool+isStripByte w = w == 0x20 || (w - 0x09) <= 4 || w == 0xA0+{-# INLINE isStripByte #-}++isDigitByte :: Word8 -> Bool+isDigitByte w = (w - 0x30) <= 9+{-# INLINE isDigitByte #-}++-- | Index of the first non-strip byte in @[i, end)@.+skipStrip :: BS.ByteString -> Int -> Int -> Int+skipStrip bs = go+ where+ go !i !end+ | i < end && isStripByte (BSU.unsafeIndex bs i) = go (i + 1) end+ | otherwise = i+{-# INLINE skipStrip #-}++-- | New exclusive end after dropping trailing strip bytes in @[i, end)@.+skipStripEnd :: BS.ByteString -> Int -> Int -> Int+skipStripEnd bs = go+ where+ go !i !end+ | end > i && isStripByte (BSU.unsafeIndex bs (end - 1)) = go i (end - 1)+ | otherwise = end+{-# INLINE skipStripEnd #-}++-- | Index of the first non-@\'0\'@ byte in @[i, end)@.+skipZeroes :: BS.ByteString -> Int -> Int -> Int+skipZeroes bs = go+ where+ go !i !end+ | i < end && BSU.unsafeIndex bs i == 0x30 = go (i + 1) end+ | otherwise = i+{-# INLINE skipZeroes #-}++{- | Consume ASCII digits from @i@, passing the stop index and the wrapping+'Word64' accumulation to the continuation. Callers must bound the+significant digit count before trusting the value.+-}+takeDigits64 ::+ forall (rep :: RuntimeRep) (r :: TYPE rep).+ BS.ByteString ->+ Int ->+ Int ->+ (Int -> Word64 -> r) ->+ r+takeDigits64 bs (I# i0) (I# end) k = go i0 (wordToWord64# 0##)+ where+ go :: Int# -> Word64# -> r+ go i acc+ | isTrue# (i >=# end) = k (I# i) (W64# acc)+ | isDigitByte w =+ case fromIntegral (w - 0x30) :: Word64 of+ W64# d ->+ go+ (i +# 1#)+ ((acc `timesWord64#` wordToWord64# 10##) `plusWord64#` d)+ | otherwise = k (I# i) (W64# acc)+ where+ w = BSU.unsafeIndex bs (I# i)+{-# INLINE takeDigits64 #-}
+ src-internal/DataFrame/Internal/Parsing/Fast/Double.hs view
@@ -0,0 +1,124 @@+{-# LANGUAGE BangPatterns #-}+{-# LANGUAGE MagicHash #-}+{-# LANGUAGE UnboxedTuples #-}++{- | Fast @Double@ slice parser, bit-exact with @readByteStringDouble@.+Replays the reference parser's exact floating-point operations via 'Word64'+digit accumulation and 10^k tables, falling back when exactness is in doubt.+-}+module DataFrame.Internal.Parsing.Fast.Double (parseDoubleField#) where++import qualified Data.ByteString as BS+import qualified Data.ByteString.Unsafe as BSU+import qualified Data.Vector.Unboxed as VU++import Data.Word (Word64)+import GHC.Exts (Double (..), Double#, Int#)++import DataFrame.Internal.Parsing (readByteStringDouble)+import DataFrame.Internal.Parsing.Fast.Common++{- | @10 ^ k@ for @k <= tableMax@, computed with the same @(^)@ the reference+parser uses, so every entry is bit-identical. Entries from @10^309@ up are+@Infinity@, so clamping larger exponents to 'tableMax' is exact.+-}+pow10Table :: VU.Vector Double+pow10Table = VU.generate (tableMax + 1) (10 ^)+{-# NOINLINE pow10Table #-}++-- | @recip (10 ^ k)@, replaying @10 ^^ negate k@ bit-for-bit.+recipPow10Table :: VU.Vector Double+recipPow10Table = VU.map recip pow10Table+{-# NOINLINE recipPow10Table #-}++tableMax :: Int+tableMax = 1024++{- | 'Word64' to 'Double' exactly as the reference parser's+'fromInteger' rounds it; values up to @2^53@ take the exact+'Int' conversion (int2Double#), larger ones the 'Integer' route.+-}+w2d :: Word64 -> Double+w2d w+ | w <= 9007199254740991 = fromIntegral (fromIntegral w :: Int)+ | otherwise = fromInteger (toInteger w)+{-# INLINE w2d #-}++-- | Exactness in doubt: hand the raw slice to the reference parser.+referenceSlice :: BS.ByteString -> Int -> Int -> (# Int#, Double# #)+referenceSlice bs start end =+ case readByteStringDouble (BSU.unsafeTake (end - start) (BSU.unsafeDrop start bs)) of+ Just (D# d) -> (# 1#, d #)+ Nothing -> (# 0#, 0.0## #)+{-# NOINLINE referenceSlice #-}++{- | Result is @(# ok, value #)@ with @ok@ 0 or 1. Caller guarantees+@0 <= start <= end <= length buf@.+-}+parseDoubleField# :: BS.ByteString -> Int -> Int -> (# Int#, Double# #)+parseDoubleField# bs start end0+ | i0 >= end = none+ | otherwise =+ let !c0 = BSU.unsafeIndex bs i0+ !neg = c0 == 0x2D+ !i1 = if neg || c0 == 0x2B then i0 + 1 else i0+ in if i1 >= end || not (isDigitByte (BSU.unsafeIndex bs i1))+ then none+ else+ let !iz = skipZeroes bs i1 end+ in takeDigits64 bs iz end $ \wEnd w ->+ if wEnd - iz > 19+ then referenceSlice bs start end0+ else afterWhole neg w wEnd+ where+ !i0 = skipStrip bs start end0+ !end = skipStripEnd bs i0 end0++ none = (# 0#, 0.0## #)++ afterWhole !neg !w !i+ | i < end && BSU.unsafeIndex bs i == 0x2E =+ let !f0 = i + 1+ !fz = skipZeroes bs f0 end+ in takeDigits64 bs fz end $ \fEnd p ->+ if fEnd == f0+ then none+ else+ if fEnd - fz > 19+ then referenceSlice bs start end0+ else afterExponent neg (w2d w + (w2d p / pow10 (fEnd - f0))) fEnd+ | otherwise = afterExponent neg (w2d w) i++ afterExponent !neg !val !i+ | i >= end = done neg val+ | BSU.unsafeIndex bs i == 0x65 || BSU.unsafeIndex bs i == 0x45 =+ let !i1 = i + 1+ !eneg = i1 < end && BSU.unsafeIndex bs i1 == 0x2D+ !i2 = if i1 < end && (eneg || BSU.unsafeIndex bs i1 == 0x2B) then i1 + 1 else i1+ in if i2 >= end || not (isDigitByte (BSU.unsafeIndex bs i2))+ then none+ else+ let !ez = skipZeroes bs i2 end+ in takeDigits64 bs ez end $ \eEnd e ->+ if eEnd /= end+ then none+ else+ if eEnd - ez > 18+ then referenceSlice bs start end0+ else done neg (val * scale eneg (fromIntegral e))+ | otherwise = none++ scale !eneg !ex+ | eneg = VU.unsafeIndex recipPow10Table k+ | otherwise = VU.unsafeIndex pow10Table k+ where+ !k = min ex tableMax+ {-# INLINE scale #-}++ pow10 !k = VU.unsafeIndex pow10Table (min k tableMax)+ {-# INLINE pow10 #-}++ done !neg !v = case if neg then negate v else v of+ D# d -> (# 1#, d #)+ {-# INLINE done #-}+{-# INLINE parseDoubleField# #-}
+ src-internal/DataFrame/Internal/Parsing/Fast/Int.hs view
@@ -0,0 +1,52 @@+{-# LANGUAGE BangPatterns #-}+{-# LANGUAGE MagicHash #-}+{-# LANGUAGE UnboxedTuples #-}++{- | Non-backtracking @Int@ slice parser, bit-compatible with+@readByteStringInt@: grammar @WS* sign? digit+ WS*@, whole slice consumed,+'Nothing' on 64-bit overflow.+-}+module DataFrame.Internal.Parsing.Fast.Int (parseIntField#) where++import qualified Data.ByteString as BS+import qualified Data.ByteString.Unsafe as BSU++import GHC.Exts (Int (..), Int#)++import DataFrame.Internal.Parsing.Fast.Common++{- | Result is @(# ok, value #)@ with @ok@ 0 or 1. Caller guarantees+@0 <= start <= end <= length buf@.+-}+parseIntField# :: BS.ByteString -> Int -> Int -> (# Int#, Int# #)+parseIntField# bs start end0+ | i0 >= end = none+ | otherwise =+ let !c0 = BSU.unsafeIndex bs i0+ !neg = c0 == 0x2D+ !i1 = if neg || c0 == 0x2B then i0 + 1 else i0+ in if i1 >= end || not (isDigitByte (BSU.unsafeIndex bs i1))+ then none+ else+ let !iz = skipZeroes bs i1 end+ in takeDigits64 bs iz end $ \dEnd w ->+ if dEnd /= end || dEnd - iz > 19+ then none+ else+ if neg+ then+ if w <= 9223372036854775808+ then done (negate (fromIntegral w))+ else none+ else+ if w <= 9223372036854775807+ then done (fromIntegral w)+ else none+ where+ !i0 = skipStrip bs start end0+ !end = skipStripEnd bs i0 end0+ none = (# 0#, 0# #)+ done :: Int -> (# Int#, Int# #)+ done (I# n) = (# 1#, n #)+ {-# INLINE done #-}+{-# INLINE parseIntField# #-}
+ src-internal/DataFrame/Internal/Parsing/Fast/Token.hs view
@@ -0,0 +1,110 @@+{-# LANGUAGE MagicHash #-}+{-# LANGUAGE UnboxedTuples #-}++{- | Byte-level token tests: Bool fields, the canonical missing-token+set, and the default @%Y-%m-%d@ date shape. All length-bucketed with+first-byte dispatch; no Text decode, no list walk.+-}+module DataFrame.Internal.Parsing.Fast.Token (+ parseBoolField#,+ isMissingFieldSlice,+ isMissingFieldIn,+ parseDateFieldSlice,+) where++import qualified Data.ByteString as BS+import qualified Data.ByteString.Unsafe as BSU++import Data.Time (Day, fromGregorianValid)+import Data.Word (Word8)+import GHC.Exts (Int#)++import DataFrame.Internal.Parsing (readByteStringDate)+import DataFrame.Internal.Parsing.Fast.Common (isDigitByte)++{- | Exactly @True|true|TRUE|False|false|FALSE@, no strip (the+'readByteStringBool' grammar). Result is @(# ok, bool #)@.+-}+parseBoolField# :: BS.ByteString -> Int -> Int -> (# Int#, Int# #)+parseBoolField# bs s e = case e - s of+ 4+ | ix 0 == 0x54 && rue 0x72 0x75 0x65 -> (# 1#, 1# #)+ | ix 0 == 0x54 && rue 0x52 0x55 0x45 -> (# 1#, 1# #)+ | ix 0 == 0x74 && rue 0x72 0x75 0x65 -> (# 1#, 1# #)+ 5+ | ix 0 == 0x46 && alse 0x61 0x6C 0x73 0x65 -> (# 1#, 0# #)+ | ix 0 == 0x46 && alse 0x41 0x4C 0x53 0x45 -> (# 1#, 0# #)+ | ix 0 == 0x66 && alse 0x61 0x6C 0x73 0x65 -> (# 1#, 0# #)+ _ -> (# 0#, 0# #)+ where+ ix d = BSU.unsafeIndex bs (s + d)+ rue a b c = ix 1 == a && ix 2 == b && ix 3 == c+ alse a b c d = ix 1 == a && ix 2 == b && ix 3 == c && ix 4 == d+{-# INLINE parseBoolField# #-}++{- | Membership in the canonical missing list+@[\"Nothing\",\"NULL\",\"\",\" \",\"nan\",\"null\",\"N\/A\",\"NaN\",\"NAN\",\"NA\"]@+(case-sensitive, exact), dispatched on length then first byte.+-}+isMissingFieldSlice :: BS.ByteString -> Int -> Int -> Bool+isMissingFieldSlice bs s e = case e - s of+ 0 -> True+ 1 -> ix 0 == 0x20+ 2 -> ix 0 == 0x4E && ix 1 == 0x41+ 3 -> case ix 0 of+ 0x6E -> ix 1 == 0x61 && ix 2 == 0x6E+ 0x4E ->+ (ix 2 == 0x4E && (ix 1 == 0x61 || ix 1 == 0x41))+ || (ix 1 == 0x2F && ix 2 == 0x41)+ _ -> False+ 4 ->+ (ix 0 == 0x4E && ix 1 == 0x55 && ix 2 == 0x4C && ix 3 == 0x4C)+ || (ix 0 == 0x6E && ix 1 == 0x75 && ix 2 == 0x6C && ix 3 == 0x6C)+ 7 ->+ ix 0 == 0x4E+ && ix 1 == 0x6F+ && ix 2 == 0x74+ && ix 3 == 0x68+ && ix 4 == 0x69+ && ix 5 == 0x6E+ && ix 6 == 0x67+ _ -> False+ where+ ix :: Int -> Word8+ ix d = BSU.unsafeIndex bs (s + d)+ {-# INLINE ix #-}+{-# INLINE isMissingFieldSlice #-}++-- | Generic fallback for user-supplied missing-indicator lists.+isMissingFieldIn :: [BS.ByteString] -> BS.ByteString -> Bool+isMissingFieldIn toks f = f `elem` toks+{-# INLINE isMissingFieldIn #-}++{- | @%Y-%m-%d@: byte-level fast path for the padded 10-byte shape+(@dddd-dd-dd@); anything else (unpadded, whitespace-tolerant, long+years, invalid) falls back to 'readByteStringDate'.+-}+parseDateFieldSlice :: BS.ByteString -> Int -> Int -> Maybe Day+parseDateFieldSlice bs s e+ | e - s == 10+ && isDigitByte (ix 0)+ && isDigitByte (ix 1)+ && isDigitByte (ix 2)+ && isDigitByte (ix 3)+ && ix 4 == 0x2D+ && isDigitByte (ix 5)+ && isDigitByte (ix 6)+ && ix 7 == 0x2D+ && isDigitByte (ix 8)+ && isDigitByte (ix 9) =+ fromGregorianValid+ (toInteger (dig 0 * 1000 + dig 1 * 100 + dig 2 * 10 + dig 3))+ (dig 5 * 10 + dig 6)+ (dig 8 * 10 + dig 9)+ | otherwise =+ readByteStringDate "%Y-%m-%d" (BSU.unsafeTake (e - s) (BSU.unsafeDrop s bs))+ where+ ix d = BSU.unsafeIndex bs (s + d)+ dig :: Int -> Int+ dig d = fromIntegral (ix d) - 0x30+{-# INLINE parseDateFieldSlice #-}
− src/DataFrame/Internal/Binary.hs
@@ -1,94 +0,0 @@-{-# LANGUAGE BangPatterns #-}--module DataFrame.Internal.Binary where--import Data.Bits (Bits (unsafeShiftL, (.|.)))-import Data.ByteString (toStrict)-import qualified Data.ByteString as BS-import Data.ByteString.Builder (toLazyByteString, word32LE, word64LE)-import qualified Data.ByteString.Unsafe as BS-import Data.Int (Int32)-import Data.Word (Word32, Word64, Word8)--littleEndianWord32 :: BS.ByteString -> Word32-littleEndianWord32 bytes- | len >= 4 =- assembleWord32- (BS.unsafeIndex bytes 0)- (BS.unsafeIndex bytes 1)- (BS.unsafeIndex bytes 2)- (BS.unsafeIndex bytes 3)- | otherwise =- assembleWord32- (byteAtOrZero len bytes 0)- (byteAtOrZero len bytes 1)- (byteAtOrZero len bytes 2)- (byteAtOrZero len bytes 3)- where- len = BS.length bytes-{-# INLINE littleEndianWord32 #-}--littleEndianWord64 :: BS.ByteString -> Word64-littleEndianWord64 bytes- | len >= 8 =- assembleWord64- (BS.index bytes 0)- (BS.index bytes 1)- (BS.index bytes 2)- (BS.index bytes 3)- (BS.index bytes 4)- (BS.index bytes 5)- (BS.index bytes 6)- (BS.index bytes 7)- | otherwise =- assembleWord64- (byteAtOrZero len bytes 0)- (byteAtOrZero len bytes 1)- (byteAtOrZero len bytes 2)- (byteAtOrZero len bytes 3)- (byteAtOrZero len bytes 4)- (byteAtOrZero len bytes 5)- (byteAtOrZero len bytes 6)- (byteAtOrZero len bytes 7)- where- len = BS.length bytes-{-# INLINE littleEndianWord64 #-}--littleEndianInt32 :: BS.ByteString -> Int32-littleEndianInt32 = fromIntegral . littleEndianWord32-{-# INLINE littleEndianInt32 #-}--word64ToLittleEndian :: Word64 -> BS.ByteString-word64ToLittleEndian = toStrict . toLazyByteString . word64LE-{-# INLINE word64ToLittleEndian #-}--word32ToLittleEndian :: Word32 -> BS.ByteString-word32ToLittleEndian = toStrict . toLazyByteString . word32LE-{-# INLINE word32ToLittleEndian #-}--byteAtOrZero :: Int -> BS.ByteString -> Int -> Word8-byteAtOrZero len bytes i- | i >= 0 && i < len = BS.unsafeIndex bytes i- | otherwise = 0-{-# INLINE byteAtOrZero #-}--assembleWord32 :: Word8 -> Word8 -> Word8 -> Word8 -> Word32-assembleWord32 !b0 !b1 !b2 !b3 =- fromIntegral b0- .|. (fromIntegral b1 `unsafeShiftL` 8)- .|. (fromIntegral b2 `unsafeShiftL` 16)- .|. (fromIntegral b3 `unsafeShiftL` 24)-{-# INLINE assembleWord32 #-}--assembleWord64 ::- Word8 -> Word8 -> Word8 -> Word8 -> Word8 -> Word8 -> Word8 -> Word8 -> Word64-assembleWord64 !b0 !b1 !b2 !b3 !b4 !b5 !b6 !b7 =- fromIntegral b0- .|. (fromIntegral b1 `unsafeShiftL` 8)- .|. (fromIntegral b2 `unsafeShiftL` 16)- .|. (fromIntegral b3 `unsafeShiftL` 24)- .|. (fromIntegral b4 `unsafeShiftL` 32)- .|. (fromIntegral b5 `unsafeShiftL` 40)- .|. (fromIntegral b6 `unsafeShiftL` 48)- .|. (fromIntegral b7 `unsafeShiftL` 56)-{-# INLINE assembleWord64 #-}
− src/DataFrame/Internal/Parsing.hs
@@ -1,238 +0,0 @@-{-# LANGUAGE CPP #-}-{-# LANGUAGE LambdaCase #-}-{-# LANGUAGE OverloadedStrings #-}-{-# LANGUAGE ScopedTypeVariables #-}--module DataFrame.Internal.Parsing where--import qualified Data.ByteString.Char8 as C-import qualified Data.Set as S-import qualified Data.Text as T-import qualified Data.Text.IO as TIO--import Control.Applicative (many, (<|>))-import Data.Attoparsec.Text hiding (decimal, double, signed)-import Data.ByteString.Lex.Fractional-import Data.Foldable (fold)-import Data.Text.Read (decimal, double, signed)-import Data.Time (Day, defaultTimeLocale, parseTimeM)-import GHC.Stack (HasCallStack)-import System.IO (Handle, IOMode (..), hIsEOF, hTell, withFile)-import Prelude hiding (takeWhile)--isNullish :: T.Text -> Bool-isNullish =- ( `S.member`- S.fromList- ["Nothing", "NULL", "", " ", "nan", "null", "N/A", "NaN", "NAN", "NA"]- )--isNullishBS :: C.ByteString -> Bool-isNullishBS =- ( `S.member`- S.fromList- ["Nothing", "NULL", "", " ", "nan", "null", "N/A", "NaN", "NAN", "NA"]- )--isTrueish :: T.Text -> Bool-isTrueish t = t `elem` ["True", "true", "TRUE"]--isFalseish :: T.Text -> Bool-isFalseish t = t `elem` ["False", "false", "FALSE"]--readBool :: (HasCallStack) => T.Text -> Maybe Bool-readBool s- | isTrueish s = Just True- | isFalseish s = Just False- | otherwise = Nothing--readByteStringBool :: C.ByteString -> Maybe Bool-readByteStringBool s- | s `elem` ["True", "true", "TRUE"] = Just True- | s `elem` ["False", "false", "FALSE"] = Just False- | otherwise = Nothing--readByteStringDate :: String -> C.ByteString -> Maybe Day-readByteStringDate fmt = parseTimeM True defaultTimeLocale fmt . C.unpack--readInteger :: (HasCallStack) => T.Text -> Maybe Integer-readInteger s = case signed decimal (T.strip s) of- Left _ -> Nothing- Right (value, "") -> Just value- Right (_value, _) -> Nothing--readInt :: (HasCallStack) => T.Text -> Maybe Int-readInt s = case signed decimal (T.strip s) of- Left _ -> Nothing- Right (value, "") -> Just value- Right (_value, _) -> Nothing-{-# INLINE readInt #-}--readByteStringInt :: (HasCallStack) => C.ByteString -> Maybe Int-#if MIN_VERSION_bytestring(0,12,0)--- bytestring >= 0.12: 'C.readInt' returns 'Nothing' on overflow.-readByteStringInt s = case C.readInt (C.strip s) of- Just (value, "") -> Just value- _ -> Nothing-#else--- bytestring < 0.12: 'C.readInt' silently wraps on overflow. Fields of--- <= 18 characters fit in an 'Int' and keep the fast path; longer ones fall--- back to arbitrary-precision 'C.readInteger' with a range check.-readByteStringInt s- | C.length t <= 18 = case C.readInt t of- Just (value, "") -> Just value- _ -> Nothing- | otherwise = case C.readInteger t of- Just (value, "")- | value >= toInteger (minBound :: Int)- , value <= toInteger (maxBound :: Int) ->- Just (fromInteger value)- _ -> Nothing- where- t = C.strip s-#endif-{-# INLINE readByteStringInt #-}--readByteStringDouble :: (HasCallStack) => C.ByteString -> Maybe Double-readByteStringDouble s =- let- readFunc = if C.any (\c -> c == 'e' || c == 'E') s then readExponential else readDecimal- in- case readSigned readFunc (C.strip s) of- Nothing -> Nothing- Just (value, "") -> Just value- Just (_value, _) -> Nothing-{-# INLINE readByteStringDouble #-}--readDouble :: (HasCallStack) => T.Text -> Maybe Double-readDouble s =- case signed double s of- Left _ -> Nothing- Right (value, "") -> Just value- Right (_value, _) -> Nothing-{-# INLINE readDouble #-}--readIntegerEither :: (HasCallStack) => T.Text -> Either T.Text Integer-readIntegerEither s = case signed decimal (T.strip s) of- Left _ -> Left s- Right (value, "") -> Right value- Right (_value, _) -> Left s-{-# INLINE readIntegerEither #-}--readIntEither :: (HasCallStack) => T.Text -> Either T.Text Int-readIntEither s = case signed decimal (T.strip s) of- Left _ -> Left s- Right (value, "") -> Right value- Right (_value, _) -> Left s-{-# INLINE readIntEither #-}--readDoubleEither :: (HasCallStack) => T.Text -> Either T.Text Double-readDoubleEither s =- case signed double s of- Left _ -> Left s- Right (value, "") -> Right value- Right (_value, _) -> Left s-{-# INLINE readDoubleEither #-}---- ------------------------------------------------------------------------------ Attoparsec CSV parser combinators (shared between Lazy.IO.CSV and others)--- -----------------------------------------------------------------------------parseSep :: Char -> T.Text -> [T.Text]-parseSep c s = either error id (parseOnly (record c) s)-{-# INLINE parseSep #-}--record :: Char -> Parser [T.Text]-record c =- field c `sepBy1` char c- <?> "record"-{-# INLINE record #-}--parseRow :: Char -> Parser [T.Text]-parseRow c = (record c <* lineEnd) <?> "record-new-line"--field :: Char -> Parser T.Text-field c =- quotedField <|> unquotedField c- <?> "field"-{-# INLINE field #-}--unquotedTerminators :: Char -> S.Set Char-unquotedTerminators sep = S.fromList [sep, '\n', '\r', '"']--unquotedField :: Char -> Parser T.Text-unquotedField sep =- takeWhile (not . (`S.member` terminators)) <?> "unquoted field"- where- terminators = unquotedTerminators sep-{-# INLINE unquotedField #-}--quotedField :: Parser T.Text-quotedField = char '"' *> contents <* char '"' <?> "quoted field"- where- contents = fold <$> many (unquote <|> unescape)- where- unquote = takeWhile1 (notInClass "\"\\")- unescape =- char '\\' *> do- T.singleton <$> do- char '\\' <|> char '"'-{-# INLINE quotedField #-}--lineEnd :: Parser ()-lineEnd =- (endOfLine <|> endOfInput)- <?> "end of line"-{-# INLINE lineEnd #-}---- | First pass to count rows for exact allocation.-countRows :: Char -> FilePath -> IO Int-countRows c path = withFile path ReadMode $! go 0 ""- where- go n input h = do- isEOF <- hIsEOF h- if isEOF && input == mempty- then pure n- else- parseWith (TIO.hGetChunk h) (parseRow c) input >>= \case- Fail unconsumed ctx er -> do- erpos <- hTell h- fail $- "Failed to parse CSV file around "- <> show erpos- <> " byte; due: "- <> show er- <> "; context: "- <> show ctx- <> " "- <> show unconsumed- Partial _ -> fail $ "Partial handler is called; n = " <> show n- Done (unconsumed :: T.Text) _ ->- go (n + 1) unconsumed h-{-# INLINE countRows #-}---- | Infer the Haskell type name from a text sample.-inferValueType :: T.Text -> T.Text-inferValueType s = case readInt s of- Just _ -> "Int"- Nothing -> case readDouble s of- Just _ -> "Double"- Nothing -> "Other"-{-# INLINE inferValueType #-}---- | Read a single CSV row from a handle using the given separator.-readSingleLine :: Char -> T.Text -> Handle -> IO ([T.Text], T.Text)-readSingleLine c unused handle =- parseWith (TIO.hGetChunk handle) (parseRow c) unused >>= \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 _ -> fail "Partial handler is called"- Done (unconsumed :: T.Text) (row :: [T.Text]) ->- return (row, unconsumed)
− src/DataFrame/Internal/Parsing/Fast.hs
@@ -1,107 +0,0 @@-{-# LANGUAGE MagicHash #-}-{-# LANGUAGE UnboxedTuples #-}--{- | Fast, non-backtracking field parsers for CSV ingest (Round-2 WS-B).--Each parser is a pure function over a @(buf, start, end)@ byte slice-(the SIMD scan already yields field boundaries) and is bit-exact with-the reference parsers in "DataFrame.Internal.Parsing": the Double fast-path covers fixed-width mantissas (<= 19 significant digits per-component) via a power-of-ten table and falls back to-'DataFrame.Internal.Parsing.readByteStringDouble' whenever exactness-is in doubt. The unboxed @#@ variants avoid all 'Maybe' boxing; the-plain names are thin 'Maybe' wrappers over the whole ByteString.--}-module DataFrame.Internal.Parsing.Fast (- -- * Int fields- parseIntField,- parseIntFieldSlice,- parseIntField#,-- -- * Double fields- parseDoubleField,- parseDoubleFieldSlice,- parseDoubleField#,-- -- * Bool fields- parseBoolField,- parseBoolFieldSlice,- parseBoolField#,-- -- * Date fields (default @%Y-%m-%d@ format)- parseDateField,- parseDateFieldSlice,-- -- * Missing-token test- isMissingField,- isMissingFieldSlice,- isMissingFieldIn,-) where--import qualified Data.ByteString as BS--import Data.Time (Day)-import GHC.Exts (Double (..), Int (..))--import DataFrame.Internal.Parsing.Fast.Double (parseDoubleField#)-import DataFrame.Internal.Parsing.Fast.Int (parseIntField#)-import DataFrame.Internal.Parsing.Fast.Token (- isMissingFieldIn,- isMissingFieldSlice,- parseBoolField#,- parseDateFieldSlice,- )--{- | Strip-tolerant @Int@ parse of a whole field; rejects overflow,-matching @readByteStringInt@ exactly.--}-parseIntField :: BS.ByteString -> Maybe Int-parseIntField bs = parseIntFieldSlice bs 0 (BS.length bs)-{-# INLINE parseIntField #-}--parseIntFieldSlice :: BS.ByteString -> Int -> Int -> Maybe Int-parseIntFieldSlice bs start end =- case parseIntField# bs start end of- (# 0#, _ #) -> Nothing- (# _, n #) -> Just (I# n)-{-# INLINE parseIntFieldSlice #-}--{- | Strip-tolerant @Double@ parse of a whole field; bit-exact with-@readByteStringDouble@ (falls back to it outside the fast window).--}-parseDoubleField :: BS.ByteString -> Maybe Double-parseDoubleField bs = parseDoubleFieldSlice bs 0 (BS.length bs)-{-# INLINE parseDoubleField #-}--parseDoubleFieldSlice :: BS.ByteString -> Int -> Int -> Maybe Double-parseDoubleFieldSlice bs start end =- case parseDoubleField# bs start end of- (# 0#, _ #) -> Nothing- (# _, d #) -> Just (D# d)-{-# INLINE parseDoubleFieldSlice #-}---- | Exact-match Bool parse (@True|true|TRUE|False|false|FALSE@, no strip).-parseBoolField :: BS.ByteString -> Maybe Bool-parseBoolField bs = parseBoolFieldSlice bs 0 (BS.length bs)-{-# INLINE parseBoolField #-}--parseBoolFieldSlice :: BS.ByteString -> Int -> Int -> Maybe Bool-parseBoolFieldSlice bs start end =- case parseBoolField# bs start end of- (# 0#, _ #) -> Nothing- (# _, b #) -> Just (I# b /= 0)-{-# INLINE parseBoolFieldSlice #-}--{- | @%Y-%m-%d@ date parse: byte-level fast path for the padded-10-byte shape, 'Data.Time.parseTimeM' fallback for everything else.--}-parseDateField :: BS.ByteString -> Maybe Day-parseDateField bs = parseDateFieldSlice bs 0 (BS.length bs)-{-# INLINE parseDateField #-}--{- | Byte-level test against the canonical missing-token list-(@Nothing NULL \"\" \" \" nan null N\/A NaN NAN NA@), no Text decode.--}-isMissingField :: BS.ByteString -> Bool-isMissingField bs = isMissingFieldSlice bs 0 (BS.length bs)-{-# INLINE isMissingField #-}
− src/DataFrame/Internal/Parsing/Fast/Common.hs
@@ -1,108 +0,0 @@-{-# LANGUAGE BangPatterns #-}-{-# LANGUAGE MagicHash #-}-{-# LANGUAGE PolyKinds #-}-{-# LANGUAGE RankNTypes #-}-{-# LANGUAGE ScopedTypeVariables #-}--{- | Shared byte-level helpers for the fast slice parsers.--All functions operate on @(buf, start, end)@ slices with the hsthrift-one-bounds-check discipline: the caller guarantees-@0 <= start <= end <= length buf@ and everything below uses 'unsafeIndex'.--}-module DataFrame.Internal.Parsing.Fast.Common (- isStripByte,- isDigitByte,- skipStrip,- skipStripEnd,- skipZeroes,- takeDigits64,-) where--import qualified Data.ByteString as BS-import qualified Data.ByteString.Unsafe as BSU--import Data.Word (Word64, Word8)-import GHC.Exts (- Int (..),- Int#,- RuntimeRep,- TYPE,- Word64#,- isTrue#,- plusWord64#,- timesWord64#,- wordToWord64#,- (+#),- (>=#),- )-import GHC.Word (Word64 (..))--{- | Bytes removed by 'Data.ByteString.Char8.strip' (Latin-1 'isSpace'):-HT LF VT FF CR SP and NBSP 0xA0. Probed over all 256 bytes; parity-with the strip-based reference parsers depends on this exact set.--}-isStripByte :: Word8 -> Bool-isStripByte w = w == 0x20 || (w - 0x09) <= 4 || w == 0xA0-{-# INLINE isStripByte #-}--isDigitByte :: Word8 -> Bool-isDigitByte w = (w - 0x30) <= 9-{-# INLINE isDigitByte #-}---- | Index of the first non-strip byte in @[i, end)@.-skipStrip :: BS.ByteString -> Int -> Int -> Int-skipStrip bs = go- where- go !i !end- | i < end && isStripByte (BSU.unsafeIndex bs i) = go (i + 1) end- | otherwise = i-{-# INLINE skipStrip #-}---- | New exclusive end after dropping trailing strip bytes in @[i, end)@.-skipStripEnd :: BS.ByteString -> Int -> Int -> Int-skipStripEnd bs = go- where- go !i !end- | end > i && isStripByte (BSU.unsafeIndex bs (end - 1)) = go i (end - 1)- | otherwise = end-{-# INLINE skipStripEnd #-}---- | Index of the first non-@\'0\'@ byte in @[i, end)@.-skipZeroes :: BS.ByteString -> Int -> Int -> Int-skipZeroes bs = go- where- go !i !end- | i < end && BSU.unsafeIndex bs i == 0x30 = go (i + 1) end- | otherwise = i-{-# INLINE skipZeroes #-}--{- | Consume ASCII digits from @i@, passing the stop index and the-wrapping 'Word64' accumulation to the (possibly unboxed-result)-continuation. Callers must bound the significant digit count before-trusting the value. The loop carries @Int#@/@Word64#@ explicitly:-GHC does not worker\/wrapper join points, so boxed loop arguments-would otherwise allocate 16 bytes per digit (ticky-verified).--}-takeDigits64 ::- forall (rep :: RuntimeRep) (r :: TYPE rep).- BS.ByteString ->- Int ->- Int ->- (Int -> Word64 -> r) ->- r-takeDigits64 bs (I# i0) (I# end) k = go i0 (wordToWord64# 0##)- where- go :: Int# -> Word64# -> r- go i acc- | isTrue# (i >=# end) = k (I# i) (W64# acc)- | isDigitByte w =- case fromIntegral (w - 0x30) :: Word64 of- W64# d ->- go- (i +# 1#)- ((acc `timesWord64#` wordToWord64# 10##) `plusWord64#` d)- | otherwise = k (I# i) (W64# acc)- where- w = BSU.unsafeIndex bs (I# i)-{-# INLINE takeDigits64 #-}
− src/DataFrame/Internal/Parsing/Fast/Double.hs
@@ -1,133 +0,0 @@-{-# LANGUAGE BangPatterns #-}-{-# LANGUAGE MagicHash #-}-{-# LANGUAGE UnboxedTuples #-}--{- | Fast @Double@ slice parser, BIT-EXACT with @readByteStringDouble@-(bytestring-lexing's @readSigned readDecimal\/readExponential@).--Instead of replaying the reference parser's Integer-mantissa chain, the-fast path replays its exact floating-point operation sequence-(@fromInteger whole + fromInteger part \/ 10^k@, then @* 10^^e@) using-'Word64' digit accumulation and tables of @10 ^ k@ \/ @recip (10 ^ k)@-built with the very same @(^)@\/'recip' calls. Whenever a component-exceeds the 19-significant-digit 'Word64' window (or the exponent could-wrap 'Int'), exactness is in doubt and we fall back to the reference-parser on the raw slice.--}-module DataFrame.Internal.Parsing.Fast.Double (parseDoubleField#) where--import qualified Data.ByteString as BS-import qualified Data.ByteString.Unsafe as BSU-import qualified Data.Vector.Unboxed as VU--import Data.Word (Word64)-import GHC.Exts (Double (..), Double#, Int#)--import DataFrame.Internal.Parsing (readByteStringDouble)-import DataFrame.Internal.Parsing.Fast.Common--{- | @10 ^ k@ for @k <= tableMax@, computed with the same @(^)@ the-reference parser uses, so every entry is bit-identical to its-runtime computation. Entries from @10^309@ up are @Infinity@, so-clamping larger exponents to 'tableMax' is exact.--}-pow10Table :: VU.Vector Double-pow10Table = VU.generate (tableMax + 1) (10 ^)-{-# NOINLINE pow10Table #-}---- | @recip (10 ^ k)@, replaying @10 ^^ negate k@ bit-for-bit.-recipPow10Table :: VU.Vector Double-recipPow10Table = VU.map recip pow10Table-{-# NOINLINE recipPow10Table #-}--tableMax :: Int-tableMax = 1024--{- | 'Word64' to 'Double' exactly as the reference parser's-'fromInteger' rounds it; values up to @2^53@ take the exact-'Int' conversion (int2Double#), larger ones the 'Integer' route.--}-w2d :: Word64 -> Double-w2d w- | w <= 9007199254740991 = fromIntegral (fromIntegral w :: Int)- | otherwise = fromInteger (toInteger w)-{-# INLINE w2d #-}---- | Exactness in doubt: hand the raw slice to the reference parser.-referenceSlice :: BS.ByteString -> Int -> Int -> (# Int#, Double# #)-referenceSlice bs start end =- case readByteStringDouble (BSU.unsafeTake (end - start) (BSU.unsafeDrop start bs)) of- Just (D# d) -> (# 1#, d #)- Nothing -> (# 0#, 0.0## #)-{-# NOINLINE referenceSlice #-}--{- | Result is @(# ok, value #)@ with @ok@ 0 or 1. Caller guarantees-@0 <= start <= end <= length buf@.--}-parseDoubleField# :: BS.ByteString -> Int -> Int -> (# Int#, Double# #)-parseDoubleField# bs start end0- | i0 >= end = none- | otherwise =- let !c0 = BSU.unsafeIndex bs i0- !neg = c0 == 0x2D- !i1 = if neg || c0 == 0x2B then i0 + 1 else i0- in if i1 >= end || not (isDigitByte (BSU.unsafeIndex bs i1))- then none- else- let !iz = skipZeroes bs i1 end- in takeDigits64 bs iz end $ \wEnd w ->- if wEnd - iz > 19- then referenceSlice bs start end0- else afterWhole neg w wEnd- where- !i0 = skipStrip bs start end0- !end = skipStripEnd bs i0 end0-- none = (# 0#, 0.0## #)-- afterWhole !neg !w !i- | i < end && BSU.unsafeIndex bs i == 0x2E =- let !f0 = i + 1- !fz = skipZeroes bs f0 end- in takeDigits64 bs fz end $ \fEnd p ->- if fEnd == f0- then none- else- if fEnd - fz > 19- then referenceSlice bs start end0- else afterExponent neg (w2d w + (w2d p / pow10 (fEnd - f0))) fEnd- | otherwise = afterExponent neg (w2d w) i-- afterExponent !neg !val !i- | i >= end = done neg val- | BSU.unsafeIndex bs i == 0x65 || BSU.unsafeIndex bs i == 0x45 =- let !i1 = i + 1- !eneg = i1 < end && BSU.unsafeIndex bs i1 == 0x2D- !i2 = if i1 < end && (eneg || BSU.unsafeIndex bs i1 == 0x2B) then i1 + 1 else i1- in if i2 >= end || not (isDigitByte (BSU.unsafeIndex bs i2))- then none- else- let !ez = skipZeroes bs i2 end- in takeDigits64 bs ez end $ \eEnd e ->- if eEnd /= end- then none- else- if eEnd - ez > 18- then referenceSlice bs start end0- else done neg (val * scale eneg (fromIntegral e))- | otherwise = none-- scale !eneg !ex- | eneg = VU.unsafeIndex recipPow10Table k- | otherwise = VU.unsafeIndex pow10Table k- where- !k = min ex tableMax- {-# INLINE scale #-}-- pow10 !k = VU.unsafeIndex pow10Table (min k tableMax)- {-# INLINE pow10 #-}-- done !neg !v = case if neg then negate v else v of- D# d -> (# 1#, d #)- {-# INLINE done #-}-{-# INLINE parseDoubleField# #-}
− src/DataFrame/Internal/Parsing/Fast/Int.hs
@@ -1,53 +0,0 @@-{-# LANGUAGE BangPatterns #-}-{-# LANGUAGE MagicHash #-}-{-# LANGUAGE UnboxedTuples #-}--{- | Non-backtracking @Int@ slice parser, bit-compatible with-@readByteStringInt@ (i.e. @Data.ByteString.Char8.readInt . strip@):-grammar @WS* sign? digit+ WS*@, whole slice consumed, 'Nothing' on-64-bit overflow.--}-module DataFrame.Internal.Parsing.Fast.Int (parseIntField#) where--import qualified Data.ByteString as BS-import qualified Data.ByteString.Unsafe as BSU--import GHC.Exts (Int (..), Int#)--import DataFrame.Internal.Parsing.Fast.Common--{- | Result is @(# ok, value #)@ with @ok@ 0 or 1. Caller guarantees-@0 <= start <= end <= length buf@.--}-parseIntField# :: BS.ByteString -> Int -> Int -> (# Int#, Int# #)-parseIntField# bs start end0- | i0 >= end = none- | otherwise =- let !c0 = BSU.unsafeIndex bs i0- !neg = c0 == 0x2D- !i1 = if neg || c0 == 0x2B then i0 + 1 else i0- in if i1 >= end || not (isDigitByte (BSU.unsafeIndex bs i1))- then none- else- let !iz = skipZeroes bs i1 end- in takeDigits64 bs iz end $ \dEnd w ->- if dEnd /= end || dEnd - iz > 19- then none- else- if neg- then- if w <= 9223372036854775808- then done (negate (fromIntegral w))- else none- else- if w <= 9223372036854775807- then done (fromIntegral w)- else none- where- !i0 = skipStrip bs start end0- !end = skipStripEnd bs i0 end0- none = (# 0#, 0# #)- done :: Int -> (# Int#, Int# #)- done (I# n) = (# 1#, n #)- {-# INLINE done #-}-{-# INLINE parseIntField# #-}
− src/DataFrame/Internal/Parsing/Fast/Token.hs
@@ -1,110 +0,0 @@-{-# LANGUAGE MagicHash #-}-{-# LANGUAGE UnboxedTuples #-}--{- | Byte-level token tests: Bool fields, the canonical missing-token-set, and the default @%Y-%m-%d@ date shape. All length-bucketed with-first-byte dispatch; no Text decode, no list walk.--}-module DataFrame.Internal.Parsing.Fast.Token (- parseBoolField#,- isMissingFieldSlice,- isMissingFieldIn,- parseDateFieldSlice,-) where--import qualified Data.ByteString as BS-import qualified Data.ByteString.Unsafe as BSU--import Data.Time (Day, fromGregorianValid)-import Data.Word (Word8)-import GHC.Exts (Int#)--import DataFrame.Internal.Parsing (readByteStringDate)-import DataFrame.Internal.Parsing.Fast.Common (isDigitByte)--{- | Exactly @True|true|TRUE|False|false|FALSE@, no strip (the-'readByteStringBool' grammar). Result is @(# ok, bool #)@.--}-parseBoolField# :: BS.ByteString -> Int -> Int -> (# Int#, Int# #)-parseBoolField# bs s e = case e - s of- 4- | ix 0 == 0x54 && rue 0x72 0x75 0x65 -> (# 1#, 1# #) -- True- | ix 0 == 0x54 && rue 0x52 0x55 0x45 -> (# 1#, 1# #) -- TRUE- | ix 0 == 0x74 && rue 0x72 0x75 0x65 -> (# 1#, 1# #) -- true- 5- | ix 0 == 0x46 && alse 0x61 0x6C 0x73 0x65 -> (# 1#, 0# #) -- False- | ix 0 == 0x46 && alse 0x41 0x4C 0x53 0x45 -> (# 1#, 0# #) -- FALSE- | ix 0 == 0x66 && alse 0x61 0x6C 0x73 0x65 -> (# 1#, 0# #) -- false- _ -> (# 0#, 0# #)- where- ix d = BSU.unsafeIndex bs (s + d)- rue a b c = ix 1 == a && ix 2 == b && ix 3 == c- alse a b c d = ix 1 == a && ix 2 == b && ix 3 == c && ix 4 == d-{-# INLINE parseBoolField# #-}--{- | Membership in the canonical missing list-@[\"Nothing\",\"NULL\",\"\",\" \",\"nan\",\"null\",\"N\/A\",\"NaN\",\"NAN\",\"NA\"]@-(case-sensitive, exact), dispatched on length then first byte.--}-isMissingFieldSlice :: BS.ByteString -> Int -> Int -> Bool-isMissingFieldSlice bs s e = case e - s of- 0 -> True- 1 -> ix 0 == 0x20 -- " "- 2 -> ix 0 == 0x4E && ix 1 == 0x41 -- NA- 3 -> case ix 0 of- 0x6E -> ix 1 == 0x61 && ix 2 == 0x6E -- nan- 0x4E ->- (ix 2 == 0x4E && (ix 1 == 0x61 || ix 1 == 0x41)) -- NaN NAN- || (ix 1 == 0x2F && ix 2 == 0x41) -- N/A- _ -> False- 4 ->- (ix 0 == 0x4E && ix 1 == 0x55 && ix 2 == 0x4C && ix 3 == 0x4C) -- NULL- || (ix 0 == 0x6E && ix 1 == 0x75 && ix 2 == 0x6C && ix 3 == 0x6C) -- null- 7 ->- ix 0 == 0x4E -- Nothing- && ix 1 == 0x6F- && ix 2 == 0x74- && ix 3 == 0x68- && ix 4 == 0x69- && ix 5 == 0x6E- && ix 6 == 0x67- _ -> False- where- ix :: Int -> Word8- ix d = BSU.unsafeIndex bs (s + d)- {-# INLINE ix #-}-{-# INLINE isMissingFieldSlice #-}---- | Generic fallback for user-supplied missing-indicator lists.-isMissingFieldIn :: [BS.ByteString] -> BS.ByteString -> Bool-isMissingFieldIn toks f = f `elem` toks-{-# INLINE isMissingFieldIn #-}--{- | @%Y-%m-%d@: byte-level fast path for the padded 10-byte shape-(@dddd-dd-dd@); anything else (unpadded, whitespace-tolerant, long-years, invalid) falls back to 'readByteStringDate'.--}-parseDateFieldSlice :: BS.ByteString -> Int -> Int -> Maybe Day-parseDateFieldSlice bs s e- | e - s == 10- && isDigitByte (ix 0)- && isDigitByte (ix 1)- && isDigitByte (ix 2)- && isDigitByte (ix 3)- && ix 4 == 0x2D- && isDigitByte (ix 5)- && isDigitByte (ix 6)- && ix 7 == 0x2D- && isDigitByte (ix 8)- && isDigitByte (ix 9) =- fromGregorianValid- (toInteger (dig 0 * 1000 + dig 1 * 100 + dig 2 * 10 + dig 3))- (dig 5 * 10 + dig 6)- (dig 8 * 10 + dig 9)- | otherwise =- readByteStringDate "%Y-%m-%d" (BSU.unsafeTake (e - s) (BSU.unsafeDrop s bs))- where- ix d = BSU.unsafeIndex bs (s + d)- dig :: Int -> Int- dig d = fromIntegral (ix d) - 0x30-{-# INLINE parseDateFieldSlice #-}
src/DataFrame/Internal/Schema.hs view
@@ -1,10 +1,14 @@ {-# LANGUAGE AllowAmbiguousTypes #-}+{-# LANGUAGE DataKinds #-} {-# LANGUAGE FlexibleContexts #-}+{-# LANGUAGE FlexibleInstances #-} {-# LANGUAGE GADTs #-} {-# LANGUAGE InstanceSigs #-} {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE TypeApplications #-} {-# LANGUAGE TypeFamilies #-}+{-# LANGUAGE TypeOperators #-}+{-# LANGUAGE UndecidableInstances #-} {- | Runtime schema representation. The Template-Haskell @deriveSchema@ splice@@ -16,14 +20,18 @@ schemaType, Schema (..), makeSchema,+ RuntimeSchema (..), ) where +import Data.Kind (Type) import qualified Data.Map as M import Data.Maybe (isJust) import qualified Data.Proxy as P import qualified Data.Text as T import Data.Type.Equality (TestEquality (..)) import DataFrame.Internal.Column (Columnable)+import DataFrame.Typed.Types (Column)+import GHC.TypeLits (KnownSymbol, symbolVal) import Type.Reflection (typeRep) -- | A runtime tag for a column’s element type.@@ -86,3 +94,32 @@ -- | Construct a 'Schema' from a list of @(columnName, schemaType)@ pairs. makeSchema :: [(T.Text, SchemaType)] -> Schema makeSchema = Schema . M.fromList++{- | The runtime 'Schema' behind a type-level schema — names /and/ element+types — so a reader can project to a schema's columns and skip inference for+them in one step.++Every column type must have a 'Read' instance, which 'Columnable' does not+imply; that is what lets the names carry their types across to a reader.++==== __Examples__+>>> :set -XTypeApplications -XDataKinds+>>> elements (runtimeSchema @'[Column "n" Int])+fromList [("n",Int)]+-}+class RuntimeSchema (cols :: [Type]) where+ runtimeSchema :: Schema++instance RuntimeSchema '[] where+ runtimeSchema = makeSchema []++instance+ (KnownSymbol name, Columnable a, Read a, RuntimeSchema rest) =>+ RuntimeSchema (Column name a ': rest)+ where+ runtimeSchema =+ Schema $+ M.insert+ (T.pack (symbolVal (P.Proxy @name)))+ (schemaType @a)+ (elements (runtimeSchema @rest))
+ src/DataFrame/Schema.hs view
@@ -0,0 +1,9 @@+{- | The runtime schema surface for the @dataframe@ ecosystem: the 'Schema'+tag, its element types, and builders to describe a frame's columns by name.+Re-exported so callers never reach into @DataFrame.Internal.Schema@.+-}+module DataFrame.Schema (+ module DataFrame.Internal.Schema,+) where++import DataFrame.Internal.Schema