packages feed

dataframe-fastcsv-1.1.0.1: tests/Operations/ReadCsv.hs

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

-- Test fixtures inspired by csv-spectrum (https://github.com/max-mapper/csv-spectrum)

module Operations.ReadCsv where

import qualified Data.List as L
import qualified Data.Map as M
import qualified Data.Text as T
import qualified Data.Text.IO as TIO
import qualified Data.Vector as V
import qualified Data.Vector.Unboxed as VU

import DataFrame.IO.CSV.Fast (CsvParseError (..))
import qualified DataFrame.IO.CSV.Fast as D

import Control.Exception (try)
import Data.Function (on)
import qualified Data.Proxy as P
import Data.Type.Equality (testEquality, (:~:) (Refl))
import DataFrame.IO.CSV (
    RaggedRowPolicy (..),
    ReadOptions (..),
    defaultReadOptions,
 )
import DataFrame.Internal.Column (Column (..), bitmapTestBit)
import qualified DataFrame.Internal.Column as DI
import DataFrame.Internal.DataFrame (
    DataFrame (..),
    columnIndices,
    columns,
    dataframeDimensions,
    getColumn,
 )
import DataFrame.Internal.Schema (Schema (..), SchemaType (..))
import System.Directory (removeFile)
import System.IO (IOMode (..), withFile)
import Test.HUnit
import Type.Reflection (typeRep)

fixtureDir :: FilePath
fixtureDir = "./tests/data/unstable_csv/"

tempDir :: FilePath
tempDir = "./tests/data/unstable_csv/"

--------------------------------------------------------------------------------
-- Pretty-printer
--------------------------------------------------------------------------------

prettyPrintCsv :: FilePath -> DataFrame -> IO ()
prettyPrintCsv = prettyPrintSeparated ','

prettyPrintTsv :: FilePath -> DataFrame -> IO ()
prettyPrintTsv = prettyPrintSeparated '\t'

prettyPrintSeparated :: Char -> FilePath -> DataFrame -> IO ()
prettyPrintSeparated sep filepath df = withFile filepath WriteMode $ \handle -> do
    let (rows, _) = dataframeDimensions df
    let headers = map fst (L.sortBy (compare `on` snd) (M.toList (columnIndices df)))
    TIO.hPutStrLn
        handle
        (T.intercalate (T.singleton sep) (map (escapeField sep) headers))
    -- Write data rows
    mapM_
        (TIO.hPutStrLn handle . T.intercalate (T.singleton sep) . getRowEscaped sep df)
        [0 .. rows - 1]

-- RFC 4180-compliant CSV field escaping: wrap in quotes if the field
-- contains the separator, a line break, or a quote; double any embedded
-- quotes so the parser can reverse the encoding.
escapeField :: Char -> T.Text -> T.Text
escapeField sep field
    | needsQuoting = T.concat ["\"", T.replace "\"" "\"\"" field, "\""]
    | otherwise = field
  where
    needsQuoting =
        T.any (\c -> c == sep || c == '\n' || c == '\r' || c == '"') field

-- | Get a row from the DataFrame with all fields escaped
getRowEscaped :: Char -> DataFrame -> Int -> [T.Text]
getRowEscaped sep df i = V.ifoldr go [] (columns df)
  where
    go :: Int -> Column -> [T.Text] -> [T.Text]
    go _ (BoxedColumn bm (c :: V.Vector a)) acc = case c V.!? i of
        Just e -> escapeField sep textRep : acc
          where
            isNull = case bm of Just bm' -> not (bitmapTestBit bm' i); Nothing -> False
            textRep =
                if isNull
                    then ""
                    else case testEquality (typeRep @a) (typeRep @T.Text) of
                        Just Refl -> e
                        Nothing -> T.pack (show e)
        Nothing -> acc
    go _ (UnboxedColumn bm c) acc = case c VU.!? i of
        Just e ->
            let isNull = case bm of Just bm' -> not (bitmapTestBit bm' i); Nothing -> False
                textRep = if isNull then "" else T.pack (show e)
             in escapeField sep textRep : acc
        Nothing -> acc

testFastCsv :: String -> FilePath -> Test
testFastCsv name csvPath = TestLabel ("fast_roundtrip_" <> name) $ TestCase $ do
    dfOriginal <- D.fastReadCsv csvPath
    let tempPath = tempDir <> "temp_fast_" <> name <> ".csv"
    prettyPrintCsv tempPath dfOriginal
    dfRoundtrip <- D.fastReadCsv tempPath
    assertEqual
        ("Fast round-trip should produce equivalent DataFrame for " <> name)
        dfOriginal
        dfRoundtrip
    removeFile tempPath

testTsv :: String -> FilePath -> Test
testTsv name tsvPath = TestLabel ("roundtrip_tsv_" <> name) $ TestCase $ do
    dfOriginal <- D.readTsvFast tsvPath
    let tempPath = tempDir <> "temp_" <> name <> ".tsv"
    prettyPrintTsv tempPath dfOriginal
    dfRoundtrip <- D.readTsvFast tempPath
    assertEqual
        ("TSV round-trip should produce equivalent DataFrame for " <> name)
        dfOriginal
        dfRoundtrip
    removeFile tempPath

-- Individual round-trip test cases for each fixture

testSimpleFast :: Test
testSimpleFast = testFastCsv "simple" (fixtureDir <> "simple.csv")

testCommaInQuotesFast :: Test
testCommaInQuotesFast = testFastCsv "comma_in_quotes" (fixtureDir <> "comma_in_quotes.csv")

testEscapedQuotesFast :: Test
testEscapedQuotesFast = testFastCsv "escaped_quotes" (fixtureDir <> "escaped_quotes.csv")

testNewlinesFast :: Test
testNewlinesFast = testFastCsv "newlines" (fixtureDir <> "newlines.csv")

testUtf8Fast :: Test
testUtf8Fast = testFastCsv "utf8" (fixtureDir <> "utf8.csv")

testQuotesAndNewlinesFast :: Test
testQuotesAndNewlinesFast = testFastCsv "quotes_and_newlines" (fixtureDir <> "quotes_and_newlines.csv")

testEmptyValuesFast :: Test
testEmptyValuesFast = testFastCsv "empty_values" (fixtureDir <> "empty_values.csv")

testJsonDataFast :: Test
testJsonDataFast = testFastCsv "json_data" (fixtureDir <> "json_data.csv")

testCrlfCsv :: Test
testCrlfCsv = TestLabel "malformed_crlf_csv" $ TestCase $ do
    df <- D.readCsvFast (fixtureDir <> "crlf.csv")
    let (rows, cols) = dataframeDimensions df
    assertEqual "crlf.csv: 2 data rows" 2 rows
    assertEqual "crlf.csv: 2 columns" 2 cols
    case getColumn "name" df of
        Nothing -> assertFailure "crlf.csv: column 'name' missing"
        Just col ->
            assertEqual
                "crlf.csv: name has no \\r"
                (DI.fromList @T.Text ["Alice", "Bob"])
                col

testCrlfTsv :: Test
testCrlfTsv = TestLabel "malformed_crlf_tsv" $ TestCase $ do
    df <- D.readTsvFast (fixtureDir <> "crlf.tsv")
    let (rows, _) = dataframeDimensions df
    assertEqual "crlf.tsv: 1 data row" 1 rows
    case getColumn "name" df of
        Nothing -> assertFailure "crlf.tsv: column 'name' missing"
        Just col ->
            assertEqual
                "crlf.tsv: name has no \\r"
                (DI.fromList @T.Text ["Alice"])
                col

testHeaderOnly :: Test
testHeaderOnly = TestLabel "malformed_header_only" $ TestCase $ do
    df <- D.readCsvFast (fixtureDir <> "header_only.csv")
    let (rows, cols) = dataframeDimensions df
    assertEqual "header_only.csv: 0 data rows" 0 rows
    assertEqual "header_only.csv: 3 columns" 3 cols
    let names = map fst . L.sortBy (compare `on` snd) . M.toList $ columnIndices df
    assertEqual "header_only.csv: column names" ["first", "second", "third"] names

testTrailingBlankLine :: Test
testTrailingBlankLine = TestLabel "malformed_trailing_blank_line" $ TestCase $ do
    df <- D.readCsvFast (fixtureDir <> "trailing_blank_line.csv")
    -- blank line contributes 1 extra delimiter; (3+3+1) div 3 = 2, numRow=1
    assertEqual
        "trailing_blank_line.csv: 1 data row visible"
        1
        (fst (dataframeDimensions df))

testAllEmptyRow :: Test
testAllEmptyRow = TestLabel "malformed_all_empty_row" $ TestCase $ do
    df <- D.readCsvFast (fixtureDir <> "all_empty_row.csv")
    assertEqual "all_empty_row.csv: 1 data row" 1 (fst (dataframeDimensions df))
    let checkEmpty colName =
            case getColumn colName df of
                Nothing -> assertFailure ("column '" <> T.unpack colName <> "' missing")
                Just col ->
                    assertEqual
                        (T.unpack colName <> " is Nothing (empty field → null)")
                        (DI.fromList @(Maybe T.Text) [Nothing])
                        col
    mapM_ checkEmpty ["a", "b", "c"]

testSingleCol :: Test
testSingleCol = TestLabel "malformed_single_col" $ TestCase $ do
    df <- D.readCsvFast (fixtureDir <> "single_col.csv")
    let (rows, cols) = dataframeDimensions df
    assertEqual "single_col.csv: 3 data rows" 3 rows
    assertEqual "single_col.csv: 1 column" 1 cols
    case getColumn "name" df of
        Nothing -> assertFailure "single_col.csv: column 'name' missing"
        Just col ->
            assertEqual
                "single_col.csv: correct values"
                (DI.fromList @T.Text ["Alice", "Bob", "Carol"])
                col

-- After the RFC 4180 alignment, whitespace inside unquoted fields is
-- preserved verbatim.  Users who want leading/trailing whitespace
-- stripped can opt in via the fastCsvTrimUnquoted knob (Step 7).
testWhitespaceFields :: Test
testWhitespaceFields = TestLabel "malformed_whitespace_fields" $ TestCase $ do
    df <- D.readCsvFast (fixtureDir <> "whitespace_fields.csv")
    assertEqual
        "whitespace_fields.csv: 2 data rows"
        2
        (fst (dataframeDimensions df))
    case getColumn "name" df of
        Nothing -> assertFailure "whitespace_fields.csv: 'name' missing"
        Just col ->
            assertEqual
                "name preserves padding"
                (DI.fromList @T.Text ["  Alice  ", "  Bob  "])
                col
    case getColumn "city" df of
        Nothing -> assertFailure "whitespace_fields.csv: 'city' missing"
        Just col ->
            assertEqual
                "city preserves padding"
                (DI.fromList @T.Text ["  New York", "  Los Angeles"])
                col

-- File: a,b,c header; row "1,2" (short); row "X,Y,Z" (full).
-- After the row-index refactor each line is classified individually:
-- the short row survives with a null-padded column 'c'; the full row
-- keeps its value.  This replaces the earlier "X bleeds from next row"
-- behaviour, which was data corruption masquerading as a passing test.
testMissingFields :: Test
testMissingFields = TestLabel "malformed_missing_fields" $ TestCase $ do
    df <- D.readCsvFast (fixtureDir <> "missing_fields.csv")
    assertEqual
        "missing_fields.csv: both rows visible"
        2
        (fst (dataframeDimensions df))
    case getColumn "a" df of
        Nothing -> assertFailure "missing_fields.csv: column 'a' missing"
        Just col ->
            assertEqual
                "missing_fields.csv: col 'a' = [1, X]"
                (DI.fromList @T.Text ["1", "X"])
                col
    case getColumn "c" df of
        Nothing -> assertFailure "missing_fields.csv: column 'c' missing"
        Just col ->
            assertEqual
                "missing_fields.csv: col 'c' pads short row with Nothing"
                (DI.fromList @(Maybe T.Text) [Nothing, Just "Z"])
                col

-- File: a,b,c header; row "1,2,3,EXTRA" (over-long).
-- Under the default ragged-row policy we read columns 0..numCol-1 and
-- silently drop the EXTRA field.  A stricter `RaggedRowPolicy = Error`
-- will come with Step 7's ReadOptions knob.
testExtraFieldsTruncate :: Test
testExtraFieldsTruncate =
    TestLabel "malformed_extra_fields_truncate" $ TestCase $ do
        df <- D.readCsvFast (fixtureDir <> "extra_fields.csv")
        assertEqual
            "extra_fields.csv: 1 data row"
            1
            (fst (dataframeDimensions df))
        assertEqual
            "extra_fields.csv: 3 columns (extras dropped)"
            3
            (snd (dataframeDimensions df))
        case getColumn "c" df of
            Nothing -> assertFailure "extra_fields.csv: 'c' missing"
            Just col ->
                assertEqual
                    "extra_fields.csv: col 'c' = [3]"
                    (DI.fromList @Int [3])
                    col

testNoTrailingNewline :: Test
testNoTrailingNewline = TestLabel "malformed_no_trailing_newline" $ TestCase $ do
    df <- D.readCsvFast (fixtureDir <> "no_trailing_newline.csv")
    assertEqual
        "no_trailing_newline.csv: 1 data row"
        1
        (fst (dataframeDimensions df))
    case getColumn "name" df of
        Nothing -> assertFailure "no_trailing_newline.csv: 'name' missing"
        Just col -> assertEqual "name = Alice" (DI.fromList @T.Text ["Alice"]) col
    case getColumn "city" df of
        Nothing -> assertFailure "no_trailing_newline.csv: 'city' missing"
        Just col ->
            assertEqual
                "city = London (synthetic delimiter worked)"
                (DI.fromList @T.Text ["London"])
                col

-- Regression: a zero-byte CSV used to divide by zero in the row-stride math
-- (`VS.length indices `div` numCol` with numCol == 0). Now it returns an
-- empty DataFrame cleanly.
testEmptyFile :: Test
testEmptyFile = TestLabel "malformed_empty_file" $ TestCase $ do
    df <- D.readCsvFast (fixtureDir <> "empty_file.csv")
    assertEqual "empty_file.csv: 0 rows" 0 (fst (dataframeDimensions df))
    assertEqual "empty_file.csv: 0 columns" 0 (snd (dataframeDimensions df))

-- RFC 4180 quote unescaping: `""` decodes to a single `"`.  The escaped
-- quote fixture contains the two canonical shapes: an embedded doubled
-- quote and a plain quoted number.
testRfc4180EscapedQuote :: Test
testRfc4180EscapedQuote =
    TestLabel "rfc4180_escaped_quote" $ TestCase $ do
        df <- D.readCsvFast (fixtureDir <> "escaped_quotes.csv")
        assertEqual
            "escaped_quotes.csv: 2 data rows"
            2
            (fst (dataframeDimensions df))
        case getColumn "b" df of
            Nothing -> assertFailure "escaped_quotes.csv: 'b' missing"
            Just col ->
                assertEqual
                    "escaped_quotes.csv: doubled quotes unescaped to single quote"
                    (DI.fromList @T.Text ["ha \"ha\" ha", "4"])
                    col

-- Whitespace inside double quotes is data, not padding, so a quoted
-- "  padded  " survives the parser unchanged.
testQuotedWhitespacePreserved :: Test
testQuotedWhitespacePreserved =
    TestLabel "quoted_whitespace_preserved" $ TestCase $ do
        let path = fixtureDir <> "quoted_whitespace.csv"
        TIO.writeFile path "a\n\"  padded  \"\n"
        df <- D.readCsvFast path
        removeFile path
        case getColumn "a" df of
            Nothing -> assertFailure "quoted_whitespace.csv: 'a' missing"
            Just col ->
                assertEqual
                    "quoted_whitespace.csv: whitespace inside quotes preserved"
                    (DI.fromList @T.Text ["  padded  "])
                    col

-- With `fastCsvOnRaggedRow = RaiseOnRagged`, the short row in
-- missing_fields.csv must be rejected with a CsvRaggedRow carrying the
-- row index and the expected/actual field counts.
testRaiseOnRagged :: Test
testRaiseOnRagged = TestLabel "raise_on_ragged" $ TestCase $ do
    let opts = defaultReadOptions{fastCsvOnRaggedRow = RaiseOnRagged}
    result <-
        try (D.fastReadCsvWithOpts opts (fixtureDir <> "missing_fields.csv"))
    case (result :: Either CsvParseError DataFrame) of
        Left (CsvRaggedRow r expected actual) -> do
            assertEqual "ragged row index" 1 r
            assertEqual "expected field count" 3 expected
            assertEqual "actual field count" 2 actual
        Left other ->
            assertFailure
                ("expected CsvRaggedRow, got " <> show other)
        Right _ ->
            assertFailure
                "fastReadCsvWithOpts RaiseOnRagged should have thrown"

-- With `fastCsvTrimUnquoted = True` we recover the legacy behaviour of
-- stripping leading/trailing whitespace from unquoted fields.
testTrimUnquotedOpt :: Test
testTrimUnquotedOpt = TestLabel "trim_unquoted_opt" $ TestCase $ do
    let opts = defaultReadOptions{fastCsvTrimUnquoted = True}
    df <- D.fastReadCsvWithOpts opts (fixtureDir <> "whitespace_fields.csv")
    case getColumn "name" df of
        Nothing -> assertFailure "name missing"
        Just col ->
            assertEqual
                "trim_unquoted_opt: name stripped"
                (DI.fromList @T.Text ["Alice", "Bob"])
                col

-- A declared 'Schema' overrides type inference: the 'a' column is
-- forced to 'Int' and 'b' to 'Double', regardless of what the first few
-- rows look like.
testSchemaPushdown :: Test
testSchemaPushdown = TestLabel "schema_pushdown" $ TestCase $ do
    let path = fixtureDir <> "schema_pushdown.csv"
    TIO.writeFile path "a,b\n1,1.5\n2,2.5\n"
    let schema =
            Schema $
                M.fromList
                    [ ("a", SType (P.Proxy @Int))
                    , ("b", SType (P.Proxy @Double))
                    ]
    df <- D.fastReadCsvWithSchema schema path
    removeFile path
    case getColumn "a" df of
        Nothing -> assertFailure "schema_pushdown: 'a' missing"
        Just col -> assertEqual "a is Int" (DI.fromList @Int [1, 2]) col
    case getColumn "b" df of
        Nothing -> assertFailure "schema_pushdown: 'b' missing"
        Just col -> assertEqual "b is Double" (DI.fromList @Double [1.5, 2.5]) col

-- `fastReadCsvProj` returns only the requested columns, in the order
-- requested; unreferenced columns never appear in the result.
testProjection :: Test
testProjection = TestLabel "projection" $ TestCase $ do
    let path = fixtureDir <> "projection.csv"
    TIO.writeFile path "a,b,c\n1,2,3\n4,5,6\n"
    df <- D.fastReadCsvProj ["c", "a"] path
    removeFile path
    let (rows, cols) = dataframeDimensions df
    assertEqual "projection: 2 rows" 2 rows
    assertEqual "projection: 2 columns" 2 cols
    let names =
            map fst . L.sortBy (compare `on` snd) . M.toList $ columnIndices df
    assertEqual "projection preserves order" ["c", "a"] names

-- An unmatched `"` used to silently swallow the rest of the file: the
-- SIMD scanner's PCLMUL quote-parity chain never resets, so every byte
-- after the stray quote becomes "inside quotes."  We now raise
-- 'CsvUnclosedQuote' rather than returning a corrupted DataFrame.
testUnclosedQuote :: Test
testUnclosedQuote = TestLabel "malformed_unclosed_quote" $ TestCase $ do
    let path = fixtureDir <> "unclosed_quote.csv"
    TIO.writeFile path "a,b\n1,\"unterminated\n"
    result <- try (D.readCsvFast path)
    removeFile path
    case (result :: Either CsvParseError DataFrame) of
        Left CsvUnclosedQuote -> return ()
        Left other ->
            assertFailure
                ("expected CsvUnclosedQuote, got " <> show other)
        Right _ ->
            assertFailure
                "readCsvFast should have thrown CsvUnclosedQuote on input with a stray quote"

-- UTF-8 BOM (EF BB BF) must be stripped before the first header is parsed.
-- Without the strip, `getColumn "name"` fails because the column is keyed
-- under the three-byte-prefixed "\xEF\xBB\xBFname".
testUtf8Bom :: Test
testUtf8Bom = TestLabel "malformed_utf8_bom" $ TestCase $ do
    df <- D.readCsvFast (fixtureDir <> "utf8_bom.csv")
    assertEqual "utf8_bom.csv: 2 data rows" 2 (fst (dataframeDimensions df))
    assertEqual "utf8_bom.csv: 2 columns" 2 (snd (dataframeDimensions df))
    let names =
            map fst . L.sortBy (compare `on` snd) . M.toList $ columnIndices df
    assertEqual "utf8_bom.csv: header names" ["name", "value"] names
    case getColumn "name" df of
        Nothing -> assertFailure "utf8_bom.csv: 'name' missing"
        Just col ->
            assertEqual
                "utf8_bom.csv: name values"
                (DI.fromList @T.Text ["Alice", "Bob"])
                col

tests :: [Test]
tests =
    [ testSimpleFast
    , testCommaInQuotesFast
    , testQuotesAndNewlinesFast
    , testEscapedQuotesFast
    , testNewlinesFast
    , testUtf8Fast
    , testQuotesAndNewlinesFast
    , testEmptyValuesFast
    , testJsonDataFast
    , testCrlfCsv
    , testCrlfTsv
    , testHeaderOnly
    , testTrailingBlankLine
    , testAllEmptyRow
    , testSingleCol
    , testWhitespaceFields
    , testMissingFields
    , testExtraFieldsTruncate
    , testNoTrailingNewline
    , testEmptyFile
    , testUtf8Bom
    , testRfc4180EscapedQuote
    , testQuotedWhitespacePreserved
    , testUnclosedQuote
    , testRaiseOnRagged
    , testTrimUnquotedOpt
    , testSchemaPushdown
    , testProjection
    ]