packages feed

dataframe-csv 2.1.0.0 → 2.2.0.0

raw patch · 5 files changed

+465/−102 lines, 5 filesPVP ok

version bump matches the API change (PVP)

API changes (from Hackage documentation)

- DataFrame.IO.CSV: [numColumns] :: ReadOptions -> Maybe Int
+ DataFrame.IO.CSV: InvalidReadOptions :: [Text] -> InvalidReadOptions
+ DataFrame.IO.CSV: [numRowsToRead] :: ReadOptions -> Maybe Int
+ DataFrame.IO.CSV: [readColumns] :: ReadOptions -> Maybe [Text]
+ DataFrame.IO.CSV: newtype InvalidReadOptions
+ DataFrame.IO.CSV: readOptionsErrors :: ReadOptions -> [Text]
+ DataFrame.IO.CSV: resolveSelection :: ReadOptions -> [Text] -> [(Text, Int)]
+ DataFrame.IO.CSV: schemaReadOptions :: Schema -> ReadOptions
+ DataFrame.IO.CSV: validateReadOptions :: ReadOptions -> IO ()
- DataFrame.IO.CSV: ReadOptions :: HeaderSpec -> TypeSpec -> SafeReadMode -> [(Text, SafeReadMode)] -> String -> Char -> Maybe Int -> [Text] -> RaggedRowPolicy -> UnclosedQuotePolicy -> Bool -> ReadOptions
+ DataFrame.IO.CSV: ReadOptions :: HeaderSpec -> TypeSpec -> SafeReadMode -> [(Text, SafeReadMode)] -> String -> Char -> Maybe Int -> Maybe [Text] -> [Text] -> RaggedRowPolicy -> UnclosedQuotePolicy -> Bool -> ReadOptions
- DataFrame.IO.CSV: readCsvWithSchema :: CsvReader
+ DataFrame.IO.CSV: readCsvWithSchema :: Schema -> FilePath -> IO DataFrame
- DataFrame.IO.CSV: type CsvReader = Schema -> FilePath -> IO DataFrame
+ DataFrame.IO.CSV: type CsvReader = ReadOptions -> FilePath -> IO DataFrame
- DataFrame.Typed.IO.CSV: readCsv :: forall (cols :: [Type]). KnownSchema cols => FilePath -> IO (TypedDataFrame cols)
+ DataFrame.Typed.IO.CSV: readCsv :: forall (cols :: [Type]). (KnownSchema cols, RuntimeSchema cols) => FilePath -> IO (TypedDataFrame cols)
- DataFrame.Typed.IO.CSV: readCsvWithError :: forall (cols :: [Type]). KnownSchema cols => FilePath -> IO (Either Text (TypedDataFrame cols))
+ DataFrame.Typed.IO.CSV: readCsvWithError :: forall (cols :: [Type]). (KnownSchema cols, RuntimeSchema cols) => FilePath -> IO (Either Text (TypedDataFrame cols))
- DataFrame.Typed.IO.CSV: readCsvWithOpts :: forall (cols :: [Type]). KnownSchema cols => ReadOptions -> FilePath -> IO (TypedDataFrame cols)
+ DataFrame.Typed.IO.CSV: readCsvWithOpts :: forall (cols :: [Type]). (KnownSchema cols, RuntimeSchema cols) => ReadOptions -> FilePath -> IO (TypedDataFrame cols)
- DataFrame.Typed.IO.CSV: readTsv :: forall (cols :: [Type]). KnownSchema cols => FilePath -> IO (TypedDataFrame cols)
+ DataFrame.Typed.IO.CSV: readTsv :: forall (cols :: [Type]). (KnownSchema cols, RuntimeSchema cols) => FilePath -> IO (TypedDataFrame cols)

Files

dataframe-csv.cabal view
@@ -1,6 +1,6 @@ cabal-version:      3.4 name:               dataframe-csv-version:            2.1.0.0+version:            2.2.0.0 synopsis:           CSV reader and writer for the dataframe ecosystem. description:     @DataFrame.IO.CSV@ — strict single-pass CSV read/write (pure
src/DataFrame/IO/CSV.hs view
@@ -13,6 +13,7 @@     readSeparated,     readCsvWithSchema,     CsvReader,+    schemaReadOptions,     decodeSeparated,     fromCsv,     fromCsvBytes,@@ -54,17 +55,49 @@ readCsv :: FilePath -> IO DataFrame readCsv = readSeparated defaultReadOptions -type CsvReader = Schema -> FilePath -> IO DataFrame+{- | A reader the lazy scan can drive: 'readSeparated' here, or+@fastReadCsvWithOpts@ from @dataframe-fastcsv@. The scan owns the+'ReadOptions', so every reader it accepts honours the projection it asks+for. +==== __Example__+@+myReader :: CsvReader+myReader opts path = D.readCsvWithOpts opts path+@+-}+type CsvReader = ReadOptions -> FilePath -> IO DataFrame++{- | Read options a scan derives from its 'Schema': the schema assigns the+column types /and/ selects the columns, so a scan reads only what its+schema names — the same contract as @scanParquet@.++==== __Example__+@+ghci> schema = D.makeSchema [("id", D.schemaType \@Int), ("name", D.schemaType \@Text)]+ghci> D.readCsvWithOpts (schemaReadOptions schema) ".\/data\/customers.csv"++@+-}+schemaReadOptions :: Schema -> ReadOptions+schemaReadOptions schema =+    defaultReadOptions+        { typeSpec =+            SpecifyTypes (M.toList (elements schema)) (typeSpec defaultReadOptions)+        , readColumns = Just (M.keys (elements schema))+        }+ {- | Schema-driven CSV reader. Coerces each column to the type declared-in 'Schema'; columns absent from the schema fall back to inference.+in 'Schema'; columns absent from the schema fall back to inference and are+still returned. To read /only/ the schema's columns, pass+'schemaReadOptions' to 'readCsvWithOpts'.  @ import qualified DataFrame as D df <- D.readCsvWithSchema schema "input.csv" @ -}-readCsvWithSchema :: CsvReader+readCsvWithSchema :: Schema -> FilePath -> IO DataFrame readCsvWithSchema schema =     readSeparated         defaultReadOptions@@ -106,22 +139,50 @@ -} readSeparated :: ReadOptions -> FilePath -> IO DataFrame readSeparated opts path = do+    validateReadOptions opts     let stripUtf8Bom b = fromMaybe b (BS.stripPrefix "\xEF\xBB\xBF" b)     csvData <- stripUtf8Bom <$> BS.readFile path     decodeCsvStrict opts csvData  {- | Decode in-memory CSV bytes into a dataframe. The result is fully forced. (Note: unlike 'readSeparated', no UTF-8 BOM is stripped.)++==== __Example__+@+ghci> D.decodeSeparated D.defaultReadOptions "id,name\\n1,Ada\\n"++@ -} decodeSeparated :: ReadOptions -> BL.ByteString -> IO DataFrame decodeSeparated opts csvData = decodeCsvStrict opts (BL.toStrict csvData) +{- | Write a dataframe to a comma-separated file.++==== __Example__+@+ghci> D.writeCsv ".\/out.csv" df+@+-} writeCsv :: FilePath -> DataFrame -> IO () writeCsv = writeSeparated ',' +{- | Write a dataframe to a tab-separated file.++==== __Example__+@+ghci> D.writeTsv ".\/out.tsv" df+@+-} writeTsv :: FilePath -> DataFrame -> IO () writeTsv = writeSeparated '\t' +{- | Write a dataframe to a file using the given field separator.++==== __Example__+@+ghci> D.writeSeparated ';' ".\/out.txt" df+@+-} writeSeparated ::     -- | Separator     Char ->@@ -131,17 +192,41 @@     IO () writeSeparated c filepath df = TIO.writeFile filepath (toSeparated c df) --- | Parse a CSV string into a DataFrame using default options.+{- | Parse a CSV string into a DataFrame using default options.++==== __Example__+@+ghci> D.fromCsv "id,name\\n1,Ada\\n"+Right (DataFrame ...)++@+-} fromCsv :: String -> IO (Either String DataFrame) fromCsv s = do     let bs = BL.fromStrict (TE.encodeUtf8 (T.pack s))     (Right <$> decodeSeparated defaultReadOptions bs)         `catch` (\(e :: SomeException) -> pure (Left (show e))) --- | Parse a lazy 'ByteString' containing CSV data into a DataFrame using default options.+{- | Parse a lazy 'ByteString' containing CSV data into a DataFrame using+default options.++==== __Example__+@+ghci> D.fromCsvBytes "id,name\\n1,Ada\\n"++@+-} fromCsvBytes :: BL.ByteString -> IO DataFrame fromCsvBytes = decodeSeparated defaultReadOptions +{- | Strip one layer of double-quote delimiters from a field, if present.++==== __Example__+>>> stripQuotes "\"hello\""+"hello"+>>> stripQuotes "hello"+"hello"+-} stripQuotes :: T.Text -> T.Text stripQuotes txt =     case T.uncons txt of
src/DataFrame/IO/CSV/Internal/Options.hs view
@@ -14,21 +14,55 @@     shouldInferFromSample,     schemaTypeMap,     typeInferenceSampleSize,+    resolveSelection,+    InvalidReadOptions (..),+    readOptionsErrors,+    validateReadOptions, ) where +import qualified Data.List as L import qualified Data.Map.Strict as M import qualified Data.Text as T +import Control.Exception (Exception, throw, throwIO)+import Control.Monad (unless)+import DataFrame.Errors (DataFrameException (ColumnsNotFoundException)) import DataFrame.Operations.Typing (SafeReadMode (..)) import DataFrame.Schema (SchemaType) -data HeaderSpec = NoHeader | UseFirstRow | ProvideNames [T.Text]+{- | Where a reader's column names come from.++==== __Example__+@+ghci> D.readCsvWithOpts D.defaultReadOptions{D.headerSpec = D.ProvideNames ["a", "b"]} "no_header.csv"++@+-}+data HeaderSpec+    = NoHeader+    | -- | Take names from the first row (the default).+      UseFirstRow+    | -- | Name the leading columns; any beyond the given list are numbered.+      ProvideNames [T.Text]     deriving (Eq, Show) +{- | How a reader decides each column's type.++==== __Example__+@+ghci> D.readCsvWithOpts D.defaultReadOptions{D.typeSpec = D.InferFromSample 500} "wide.csv"++@+-} data TypeSpec-    = InferFromSample Int-    | SpecifyTypes [(T.Text, SchemaType)] TypeSpec-    | NoInference+    = -- | Infer each column's type from the first @n@ rows.+      InferFromSample Int+    | {- | Pin the listed columns to their given types; fall back to the+      nested 'TypeSpec' for the rest.+      -}+      SpecifyTypes [(T.Text, SchemaType)] TypeSpec+    | -- | Every column is read as 'Data.Text.Text', no inference at all.+      NoInference  {- | How the fast reader should treat a row whose field count does not match the header row.  Only consulted by @dataframe-fastcsv@; the pure@@ -87,10 +121,27 @@     -}     , columnSeparator :: Char     -- ^ Character that separates column values.-    , numColumns :: Maybe Int-    {- ^ Maximum number of data rows to read ('Nothing' = all). Note: despite-    the legacy name, this has always capped /rows/, not columns.+    , numRowsToRead :: Maybe Int+    {- ^ Maximum number of data rows to read ('Nothing' = all).++    __Example:__++    @+    ghci> D.readCsvWithOpts D.defaultReadOptions{D.numRowsToRead = Just 5} "big.csv"+    @     -}+    , readColumns :: Maybe [T.Text]+    {- ^ Columns to read ('Nothing' = all). Unselected columns are never+    decoded, parsed or allocated; the returned frame carries the selected+    columns in the order given here. Naming a column the file does not have+    raises a 'ColumnsNotFoundException'.++    __Example:__++    @+    ghci> D.readCsvWithOpts D.defaultReadOptions{D.readColumns = Just ["id", "name"]} "customers.csv"+    @+    -}     , missingIndicators :: [T.Text]     -- ^ Values that should be read as `Nothing`.     , fastCsvOnRaggedRow :: RaggedRowPolicy@@ -108,20 +159,143 @@     -}     } +{- | Whether a 'TypeSpec' still infers types from a sample, once any+'SpecifyTypes' fallback chain is followed to its end.++==== __Example__+>>> shouldInferFromSample (InferFromSample 100)+True+>>> shouldInferFromSample NoInference+False+-} shouldInferFromSample :: TypeSpec -> Bool shouldInferFromSample (InferFromSample _) = True shouldInferFromSample (SpecifyTypes _ fallback) = shouldInferFromSample fallback shouldInferFromSample _ = False +{- | The explicit column\/type pairs a 'TypeSpec' declares, if any.++==== __Example__+>>> :set -XTypeApplications+>>> M.toList (schemaTypeMap (SpecifyTypes [("id", schemaType @Int)] (InferFromSample 100)))+[("id",Int)]+-} schemaTypeMap :: TypeSpec -> M.Map T.Text SchemaType schemaTypeMap (SpecifyTypes xs _) = M.fromList xs schemaTypeMap _ = M.empty +{- | A 'ReadOptions' value that contradicts itself, with every problem listed.+Thrown by 'validateReadOptions'; see 'readOptionsErrors' for the check+without the exception.++==== __Example__+>>> show (InvalidReadOptions ["readColumns is an empty selection; omit it to read every column"])+"Invalid ReadOptions:\n  - readColumns is an empty selection; omit it to read every column"+-}+newtype InvalidReadOptions = InvalidReadOptions [T.Text]++instance Show InvalidReadOptions where+    show (InvalidReadOptions problems) =+        T.unpack+            (T.intercalate "\n" ("Invalid ReadOptions:" : map ("  - " <>) problems))++instance Exception InvalidReadOptions++{- | Everything self-contradictory about @opts@, judged without looking at a+file. A type or safe-read override naming an unread column is redundant, not+contradictory, and is not reported.++==== __Example__+>>> readOptionsErrors defaultReadOptions{readColumns = Just []}+["readColumns is an empty selection; omit it to read every column"]+>>> readOptionsErrors defaultReadOptions+[]+-}+readOptionsErrors :: ReadOptions -> [T.Text]+readOptionsErrors opts =+    concat+        [ [ "readColumns is an empty selection; omit it to read every column"+          | Just [] <- [readColumns opts]+          ]+        , [ "readColumns names "+                <> T.intercalate ", " dups+                <> " more than once"+          | not (null dups)+          ]+        , [ "numRowsToRead is negative: " <> T.pack (show n)+          | Just n <- [numRowsToRead opts]+          , n < 0+          ]+        , [ "headerSpec is ProvideNames with no names"+          | ProvideNames [] <- [headerSpec opts]+          ]+        , [ "columnSeparator is " <> T.pack (show sep) <> ", which cannot delimit fields"+          | let sep = columnSeparator opts+          , sep `elem` ['"', '\n', '\r']+          ]+        ]+  where+    dups = maybe [] (\cs -> L.nub (cs L.\\ L.nub cs)) (readColumns opts)++{- | Throw 'InvalidReadOptions' if @opts@ contradicts itself. Both readers+call this before opening the file.++==== __Example__+@+ghci> D.validateReadOptions D.defaultReadOptions{D.readColumns = Just []}+*** Exception: Invalid ReadOptions:+  - readColumns is an empty selection; omit it to read every column+@+-}+validateReadOptions :: ReadOptions -> IO ()+validateReadOptions opts =+    unless (null problems) (throwIO (InvalidReadOptions problems))+  where+    problems = readOptionsErrors opts++{- | Resolve 'readColumns' against a file's header names: the columns to+build, each paired with its field index within a row. 'Nothing' keeps every+column in file order; a selection keeps the order it was written in.++==== __Example__+>>> resolveSelection defaultReadOptions{readColumns = Just ["b", "a"]} ["a", "b", "c"]+[("b",1),("a",0)]+>>> resolveSelection defaultReadOptions ["a", "b"]+[("a",0),("b",1)]+-}+resolveSelection :: ReadOptions -> [T.Text] -> [(T.Text, Int)]+resolveSelection opts names = case readColumns opts of+    Nothing -> indexed+    Just requested -> case filter (`M.notMember` fieldIndex) requested of+        [] -> [(n, fieldIndex M.! n) | n <- requested]+        missing -> throw (ColumnsNotFoundException missing "readCsvWithOpts" names)+  where+    indexed = zip names [0 ..]+    fieldIndex = M.fromList indexed++{- | The sample size a 'TypeSpec' infers from, once any 'SpecifyTypes'+fallback chain is followed to its end. @0@ when the chain ends in+'NoInference'.++==== __Example__+>>> typeInferenceSampleSize (InferFromSample 100)+100+>>> typeInferenceSampleSize NoInference+0+-} typeInferenceSampleSize :: TypeSpec -> Int typeInferenceSampleSize (InferFromSample n) = n typeInferenceSampleSize (SpecifyTypes _ fallback) = typeInferenceSampleSize fallback typeInferenceSampleSize _ = 0 +{- | The default 'ReadOptions': infer types from a 100-row sample, treat the+first row as a header, read every row and every column.++==== __Example__+@+ghci> D.readCsvWithOpts D.defaultReadOptions{D.columnSeparator = ';'} "data.csv"+@+-} defaultReadOptions :: ReadOptions defaultReadOptions =     ReadOptions@@ -131,7 +305,8 @@         , safeReadOverrides = []         , dateFormat = "%Y-%m-%d"         , columnSeparator = ','-        , numColumns = Nothing+        , numRowsToRead = Nothing+        , readColumns = Nothing         , missingIndicators =             ["Nothing", "NULL", "", " ", "nan", "null", "N/A", "NaN", "NAN", "NA"]         , fastCsvOnRaggedRow = PadWithNull
src/DataFrame/IO/CSV/Internal/Read.hs view
@@ -17,6 +17,7 @@ import qualified Data.Text as T import qualified Data.Text.Encoding as TE import qualified Data.Vector as V+import qualified Data.Vector.Unboxed as VU  import Control.Monad (when, zipWithM) import Control.Monad.ST (stToIO)@@ -42,87 +43,104 @@  -- | Decode a strict CSV buffer into a fully forced DataFrame. decodeCsvStrict :: ReadOptions -> BS.ByteString -> IO DataFrame-decodeCsvStrict opts bs = BSU.unsafeUseAsCStringLen bs $ \(cstr, _) -> do-    let !len = BS.length bs-        !sep = fromIntegral (ord (columnSeparator opts)) :: Word8+decodeCsvStrict opts bs = do+    validateReadOptions opts+    BSU.unsafeUseAsCStringLen bs $ \(cstr, _) -> do+        let !len = BS.length bs+            !sep = fromIntegral (ord (columnSeparator opts)) :: Word8 -        findRecord !pos-            | pos >= len = len-            | otherwise = withField bs len sep pos $ \cs ce _ term next ->-                if cs == ce && term /= termSep-                    then if term == termEof then len else findRecord next-                    else pos+            findRecord !pos+                | pos >= len = len+                | otherwise = withField bs len sep pos $ \cs ce _ term next ->+                    if cs == ce && term /= termSep+                        then if term == termEof then len else findRecord next+                        else pos -        headerFields !pos = go pos id-          where-            go !p acc = withField bs len sep p $ \cs ce unesc term next ->-                let t =-                        TE.decodeUtf8Lenient-                            (if unesc then unescapeQuotes bs cs ce else sliceBS bs cs ce)-                 in if term == termSep-                        then go next (acc . (t :))-                        else (acc [t], next)+            headerFields !pos = go pos id+              where+                go !p acc = withField bs len sep p $ \cs ce unesc term next ->+                    let t =+                            TE.decodeUtf8Lenient+                                (if unesc then unescapeQuotes bs cs ce else sliceBS bs cs ce)+                     in if term == termSep+                            then go next (acc . (t :))+                            else (acc [t], next) -    let hdrPos = findRecord 0-    when (hdrPos >= len) (error "Empty CSV file")-    let (hdrFields, afterHdr) = headerFields hdrPos-        positional n = map (T.pack . show) [0 .. n - 1 :: Int]-        (names, dataPos0) = case headerSpec opts of-            UseFirstRow -> (map T.strip hdrFields, afterHdr)-            NoHeader -> (positional (length hdrFields), hdrPos)-            ProvideNames ns ->-                (ns ++ drop (length ns) (positional (length hdrFields)), hdrPos)-        !ncols = length names-        dataPos = findRecord dataPos0-    when (dataPos >= len) (error "Empty CSV file")+        let hdrPos = findRecord 0+        when (hdrPos >= len) (error "Empty CSV file")+        let (hdrFields, afterHdr) = headerFields hdrPos+            positional n = map (T.pack . show) [0 .. n - 1 :: Int]+            (names, dataPos0) = case headerSpec opts of+                UseFirstRow -> (map T.strip hdrFields, afterHdr)+                NoHeader -> (positional (length hdrFields), hdrPos)+                ProvideNames ns ->+                    (ns ++ drop (length ns) (positional (length hdrFields)), hdrPos)+            !nfields = length names+            dataPos = findRecord dataPos0+        when (dataPos >= len) (error "Empty CSV file") -    let resolveMode = effectiveSafeRead (safeRead opts) (safeReadOverrides opts)-        anyEither = any (\n -> resolveMode n == EitherRead) names-        missing = if anyEither then [] else missingIndicators opts-        missMode-            | null missing = MissNone-            | missing == missingIndicators defaultReadOptions = MissCanonical-            | otherwise = MissCustom-        env = Env bs (castPtr cstr) missMode missing-        rowHint = len `div` max 1 (ncols * 8) + 16+        let selection = resolveSelection opts names+            !keptNames = map fst selection+            !ncols = length selection+            !sinkOf =+                VU.replicate nfields (-1)+                    VU.// [(fieldIx, slot) | (slot, (_, fieldIx)) <- zip [0 ..] selection] -    sinks <- mapM (newSink opts resolveMode rowHint) names-    let !sinksV = V.fromList sinks+        let resolveMode = effectiveSafeRead (safeRead opts) (safeReadOverrides opts)+            anyEither = any (\n -> resolveMode n == EitherRead) keptNames+            missing = if anyEither then [] else missingIndicators opts+            missMode+                | null missing = MissNone+                | missing == missingIndicators defaultReadOptions = MissCanonical+                | otherwise = MissCustom+            env = Env bs (castPtr cstr) missMode missing+            rowHint = len `div` max 1 (nfields * 8) + 16 -    let pad !row !col-            | col >= ncols = pure ()-            | otherwise = nullSink (V.unsafeIndex sinksV col) row >> pad row (col + 1)-        dec b = if b > 0 then b - 1 else b-        rowLoop !pos !row !budget-            | budget == 0 || pos >= len = pure row-            | otherwise = withField bs len sep pos $ \cs ce unesc term next ->-                if cs == ce && term /= termSep-                    then if term == termEof then pure row else rowLoop next row budget-                    else do-                        feedSink env (V.unsafeIndex sinksV 0) row cs ce unesc-                        if term == termSep-                            then fieldLoop next row 1 budget-                            else pad row 1 >> rowLoop next (row + 1) (dec budget)-        fieldLoop !pos !row !col !budget =-            withField bs len sep pos $ \cs ce unesc term next -> do-                when (col < ncols) $-                    feedSink env (V.unsafeIndex sinksV col) row cs ce unesc-                if term == termSep-                    then fieldLoop next row (col + 1) budget-                    else pad row (col + 1) >> rowLoop next (row + 1) (dec budget)+        sinks <- mapM (newSink opts resolveMode rowHint) keptNames+        let !sinksV = V.fromList sinks+            sinkAt !col+                | col >= nfields = -1+                | otherwise = VU.unsafeIndex sinkOf col -    nrows <- rowLoop dataPos 0 (fromMaybe (-1) (numColumns opts))+        let pad !row !col+                | col >= nfields = pure ()+                | otherwise = do+                    let !slot = VU.unsafeIndex sinkOf col+                    when (slot >= 0) $ nullSink (V.unsafeIndex sinksV slot) row+                    pad row (col + 1)+            feedAt !slot !row !cs !ce !unesc =+                when (slot >= 0) $+                    feedSink env (V.unsafeIndex sinksV slot) row cs ce unesc+            dec b = if b > 0 then b - 1 else b+            rowLoop !pos !row !budget+                | budget == 0 || pos >= len = pure row+                | otherwise = withField bs len sep pos $ \cs ce unesc term next ->+                    if cs == ce && term /= termSep+                        then if term == termEof then pure row else rowLoop next row budget+                        else do+                            feedAt (sinkAt 0) row cs ce unesc+                            if term == termSep+                                then fieldLoop next row 1 budget+                                else pad row 1 >> rowLoop next (row + 1) (dec budget)+            fieldLoop !pos !row !col !budget =+                withField bs len sep pos $ \cs ce unesc term next -> do+                    feedAt (sinkAt col) row cs ce unesc+                    if term == termSep+                        then fieldLoop next row (col + 1) budget+                        else pad row (col + 1) >> rowLoop next (row + 1) (dec budget) -    cols <- zipWithM (finalizeSink bs opts resolveMode nrows) names sinks-    let df =-            DataFrame-                (V.fromList cols)-                (M.fromList (zip names [0 ..]))-                (nrows, ncols)-                M.empty-    pure $!-        forceDataFrame-            (parseWithTypes resolveMode (schemaTypeMap (typeSpec opts)) df)+        nrows <- rowLoop dataPos 0 (fromMaybe (-1) (numRowsToRead opts))++        cols <- zipWithM (finalizeSink bs opts resolveMode nrows) keptNames sinks+        let df =+                DataFrame+                    (V.fromList cols)+                    (M.fromList (zip keptNames [0 ..]))+                    (nrows, ncols)+                    M.empty+        pure $!+            forceDataFrame+                (parseWithTypes resolveMode (schemaTypeMap (typeSpec opts)) df)  {- | Resolve a column's sink: EitherRead and to-be-inferred columns collect raw slices; schema'd Int/Double parse straight into builders;
src/DataFrame/Typed/IO/CSV.hs view
@@ -1,6 +1,20 @@+{-# LANGUAGE AllowAmbiguousTypes #-} {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE TypeApplications #-} +{- | Typed CSV reading.++The schema is the whole specification of the read: it names the columns to+fetch and gives their types, so these readers touch only the columns @cols@+declares and never infer a type the schema already knows.++@+type Customer = '[Column \"customer_id\" Int, Column \"customer_name\" Text]++customers <- readCsv \@Customer \"customers.csv\"  -- reads 2 columns, whatever+                                                 -- else the file holds+@+-} module DataFrame.Typed.IO.CSV (     readCsv,     readCsvWithError,@@ -10,42 +24,113 @@     writeTsv, ) where +import Control.Applicative ((<|>))+import Control.Exception (SomeException, try) import qualified Data.Text as T -import DataFrame.IO.CSV (ReadOptions)+import DataFrame.IO.CSV (ReadOptions (..), TypeSpec (..), defaultReadOptions) import qualified DataFrame.IO.CSV as CSV+import DataFrame.Schema (RuntimeSchema (..), elements) import DataFrame.Typed.Freeze (freezeOrThrow, freezeWithError, thaw) import DataFrame.Typed.Schema (KnownSchema) import DataFrame.Typed.Types (TypedDataFrame) --- | Read a CSV file into a typed DataFrame, throwing on schema mismatch.+import qualified Data.Map.Strict as M++{- | Fold a type-level schema into read options: fetch exactly its columns,+and parse them at its types. Anything else the caller asked for is kept,+including a wider 'typeSpec' for columns the schema does not name.+-}+schemaOptions :: forall cols. (RuntimeSchema cols) => ReadOptions -> ReadOptions+schemaOptions opts =+    opts+        { typeSpec = SpecifyTypes (M.toList declared) (typeSpec opts)+        , readColumns = readColumns opts <|> Just (M.keys declared)+        }+  where+    declared = elements (runtimeSchema @cols)++{- | Read a CSV file into a typed DataFrame, throwing on schema mismatch.+Reads only the columns @cols@ names, typed as @cols@ says.++==== __Example__+@+ghci> type Customer = '[Column \"id\" Int, Column \"name\" Text]+ghci> customers <- readCsv \@Customer \"customers.csv\"+@+-} readCsv ::-    forall cols. (KnownSchema cols) => FilePath -> IO (TypedDataFrame cols)-readCsv path = CSV.readCsv path >>= freezeOrThrow @cols+    forall cols.+    (KnownSchema cols, RuntimeSchema cols) =>+    FilePath -> IO (TypedDataFrame cols)+readCsv = readCsvWithOpts @cols defaultReadOptions --- | Read a CSV file, returning a descriptive error on schema mismatch.+{- | Read a CSV file, returning a descriptive error on schema mismatch or a+missing column instead of throwing.++==== __Example__+@+ghci> readCsvWithError \@Customer \"customers.csv\"+Right (TDF ...)+@+-} readCsvWithError ::     forall cols.-    (KnownSchema cols) =>+    (KnownSchema cols, RuntimeSchema cols) =>     FilePath -> IO (Either T.Text (TypedDataFrame cols))-readCsvWithError path = freezeWithError <$> CSV.readCsv path+readCsvWithError path = do+    -- The projection can fail before the freeze does (a column the file does+    -- not have); this reader reports rather than throws either way.+    r <- try (CSV.readCsvWithOpts (schemaOptions @cols defaultReadOptions) path)+    pure $ case r of+        Left (e :: SomeException) -> Left (T.pack (show e))+        Right df -> freezeWithError df --- | Read a tab-separated file into a typed DataFrame, throwing on mismatch.+{- | Read a tab-separated file into a typed DataFrame, throwing on mismatch.++==== __Example__+@+ghci> customers <- readTsv \@Customer \"customers.tsv\"+@+-} readTsv ::-    forall cols. (KnownSchema cols) => FilePath -> IO (TypedDataFrame cols)-readTsv path = CSV.readTsv path >>= freezeOrThrow @cols+    forall cols.+    (KnownSchema cols, RuntimeSchema cols) =>+    FilePath -> IO (TypedDataFrame cols)+readTsv = readCsvWithOpts @cols defaultReadOptions{columnSeparator = '\t'} --- | Read a CSV file with custom options, throwing on schema mismatch.+{- | Read a CSV file with custom options, throwing on schema mismatch. The+schema still supplies the column selection and types; an explicit+'readColumns' takes precedence, and is then checked by the freeze.++==== __Example__+@+ghci> customers <- readCsvWithOpts \@Customer defaultReadOptions{safeRead = MaybeRead} \"customers.csv\"+@+-} readCsvWithOpts ::     forall cols.-    (KnownSchema cols) =>+    (KnownSchema cols, RuntimeSchema cols) =>     ReadOptions -> FilePath -> IO (TypedDataFrame cols)-readCsvWithOpts opts path = CSV.readCsvWithOpts opts path >>= freezeOrThrow @cols+readCsvWithOpts opts path =+    CSV.readCsvWithOpts (schemaOptions @cols opts) path >>= freezeOrThrow @cols --- | Write a typed DataFrame to a CSV file.+{- | Write a typed DataFrame to a CSV file.++==== __Example__+@+ghci> writeCsv \"customers.csv\" customers+@+-} writeCsv :: FilePath -> TypedDataFrame cols -> IO () writeCsv path = CSV.writeCsv path . thaw --- | Write a typed DataFrame to a tab-separated file.+{- | Write a typed DataFrame to a tab-separated file.++==== __Example__+@+ghci> writeTsv \"customers.tsv\" customers+@+-} writeTsv :: FilePath -> TypedDataFrame cols -> IO () writeTsv path = CSV.writeTsv path . thaw