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 +1/−1
- src/DataFrame/IO/CSV.hs +90/−5
- src/DataFrame/IO/CSV/Internal/Options.hs +183/−8
- src/DataFrame/IO/CSV/Internal/Read.hs +91/−73
- src/DataFrame/Typed/IO/CSV.hs +100/−15
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