packages feed

dataframe-fastcsv-1.1.1.0: tests/Operations/TypedExtraction.hs

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

{- | Round-2 (WS-E1) behavior pins for the typed-extraction fastcsv path:
schema bypass, unified WS-B parser semantics, byte-level missing tests,
and the chunk-boundary scalar tail that replaced the full-file copy.
-}
module Operations.TypedExtraction (tests) where

import qualified Data.Map as M
import qualified Data.Proxy as P
import qualified Data.Text as T
import qualified Data.Text.Encoding as TE
import qualified Data.Text.IO as TIO

import Control.Exception (ErrorCall, evaluate, try)
import Data.Time (Day)
import DataFrame.IO.CSV (
    ReadOptions (..),
    TypeSpec (..),
    defaultReadOptions,
 )
import qualified DataFrame.IO.CSV.Fast as D
import DataFrame.Internal.Column (Column)
import qualified DataFrame.Internal.Column as DI
import DataFrame.Internal.DataFrame (DataFrame, dataframeDimensions, getColumn)
import DataFrame.Internal.Schema (Schema (..), SchemaType (..), elements)
import DataFrame.Operations.Typing (SafeReadMode (..))
import System.Directory (removeFile)
import Test.HUnit

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

withCsv :: String -> T.Text -> (FilePath -> IO a) -> IO a
withCsv name body action = do
    let path = tmpDir <> "typed_" <> name <> ".csv"
    TIO.writeFile path body
    r <- action path
    removeFile path
    pure r

expectColumn :: String -> T.Text -> DataFrame -> Column -> Assertion
expectColumn what name df expected = case getColumn name df of
    Nothing -> assertFailure (what <> ": column missing")
    Just col -> assertEqual what expected col

-- Divergence #1 (pinned new behavior): Int overflow rejects instead of
-- wrapping, so an overflowing cell demotes the column to Double.
testIntOverflowDemotesToDouble :: Test
testIntOverflowDemotesToDouble = TestLabel "typed_int_overflow_demotes" $
    TestCase $
        withCsv "overflow" "a\n1\n9223372036854775808\n" $ \path -> do
            df <- D.fastReadCsv path
            expectColumn
                "overflowing int column becomes Double"
                "a"
                df
                (DI.fromList @Double [1.0, 9.223372036854776e18])

-- Divergence #2 (pinned new behavior): doubles parse with strip-equivalent
-- semantics, so a padded double past the 100-row inference sample no longer
-- demotes the whole column to Text. (Inside the sample the Text-level
-- classifier still rejects padding, by design.)
testPaddedDoubleStaysDouble :: Test
testPaddedDoubleStaysDouble = TestLabel "typed_padded_double" $
    TestCase $ do
        let clean = [fromIntegral i + 0.5 :: Double | i <- [1 .. 120 :: Int]]
            cleanRows = T.unlines (map (T.pack . show) clean)
        withCsv "padded_double" ("a\n" <> cleanRows <> " 3.5\n") $ \path -> do
            df <- D.fastReadCsv path
            expectColumn
                "padded double cell past the sample stays Double"
                "a"
                df
                (DI.fromList @Double (clean <> [3.5]))

-- Schema bypasses inference: a numeric-looking column declared Text in the
-- schema must come back as Text (old code silently kept the inferred Int).
testSchemaTextBypassesInference :: Test
testSchemaTextBypassesInference = TestLabel "typed_schema_text" $
    TestCase $
        withCsv "schema_text" "a\n1\n2\n" $ \path -> do
            let schema = Schema (M.fromList [("a", SType (P.Proxy @T.Text))])
            df <- D.fastReadCsvWithSchema schema path
            expectColumn
                "schema Text wins over inferred Int"
                "a"
                df
                (DI.fromList @T.Text ["1", "2"])

-- Schema + NoSafeRead: a cell that cannot parse as the declared type
-- raises during the read (strict read), instead of hiding an error thunk.
testSchemaStrictFailureThrows :: Test
testSchemaStrictFailureThrows = TestLabel "typed_schema_strict_throws" $
    TestCase $
        withCsv "schema_strict" "a\n1\nx\n" $ \path -> do
            let schema = Schema (M.fromList [("a", SType (P.Proxy @Int))])
            result <- try @ErrorCall $ do
                df <- D.fastReadCsvWithSchema schema path
                _ <- evaluate (dataframeDimensions df)
                case getColumn "a" df of
                    Just col -> evaluate (show col)
                    Nothing -> pure ""
            case result of
                Left _ -> pure ()
                Right _ ->
                    assertFailure
                        "schema Int over unparseable cell should raise under NoSafeRead"

-- Schema + MaybeRead: unparseable cells become nulls.
testSchemaMaybeReadNullsFailures :: Test
testSchemaMaybeReadNullsFailures = TestLabel "typed_schema_mayberead" $
    TestCase $
        withCsv "schema_maybe" "a\n1\nx\n2\n" $ \path -> do
            let schema = Schema (M.fromList [("a", SType (P.Proxy @Int))])
                opts =
                    defaultReadOptions
                        { typeSpec =
                            SpecifyTypes
                                (M.toList (elements schema))
                                (typeSpec defaultReadOptions)
                        , safeRead = MaybeRead
                        }
            df <- D.fastReadCsvWithOpts opts path
            expectColumn
                "schema Int + MaybeRead nulls the bad cell"
                "a"
                df
                (DI.fromList @(Maybe Int) [Just 1, Nothing, Just 2])

-- Preserved: NoSafeRead treats only empty cells as null, so "NA" still
-- demotes a numeric column to Text.
testNoSafeReadNADemotesToText :: Test
testNoSafeReadNADemotesToText = TestLabel "typed_nosaferead_na" $
    TestCase $
        withCsv "na_demotes" "a\n1\nNA\n" $ \path -> do
            df <- D.fastReadCsv path
            expectColumn
                "NA is data under NoSafeRead"
                "a"
                df
                (DI.fromList @T.Text ["1", "NA"])

-- Preserved: MaybeRead honours the canonical missing-token set.
testMaybeReadMissingTokens :: Test
testMaybeReadMissingTokens = TestLabel "typed_mayberead_missing" $
    TestCase $
        withCsv "maybe_missing" "a\n1\nNA\n2\n" $ \path -> do
            let opts = defaultReadOptions{safeRead = MaybeRead}
            df <- D.fastReadCsvWithOpts opts path
            expectColumn
                "NA is null under MaybeRead"
                "a"
                df
                (DI.fromList @(Maybe Int) [Just 1, Nothing, Just 2])

-- Preserved: custom missing indicators extend the canonical set.
testMaybeReadCustomMissingToken :: Test
testMaybeReadCustomMissingToken = TestLabel "typed_mayberead_custom" $
    TestCase $
        withCsv "maybe_custom" "a\n1\nMISSING\n2\n" $ \path -> do
            let opts =
                    defaultReadOptions
                        { safeRead = MaybeRead
                        , missingIndicators =
                            "MISSING" : missingIndicators defaultReadOptions
                        }
            df <- D.fastReadCsvWithOpts opts path
            expectColumn
                "custom token is null under MaybeRead"
                "a"
                df
                (DI.fromList @(Maybe Int) [Just 1, Nothing, Just 2])

-- Preserved: quoted numerics parse as numbers (quote stripping happens
-- before the typed parse).
testQuotedNumbersParse :: Test
testQuotedNumbersParse = TestLabel "typed_quoted_numbers" $
    TestCase $
        withCsv "quoted_nums" "a,b\n\"42\",\"1.5\"\n\"7\",\"2.5\"\n" $ \path -> do
            df <- D.fastReadCsv path
            expectColumn "quoted ints" "a" df (DI.fromList @Int [42, 7])
            expectColumn "quoted doubles" "b" df (DI.fromList @Double [1.5, 2.5])

-- Preserved: Bool and Date inference still land on typed columns.
testBoolAndDateInference :: Test
testBoolAndDateInference = TestLabel "typed_bool_date" $
    TestCase $
        withCsv "bool_date" "b,d\nTrue,2020-01-01\nFalse,2021-12-31\n" $ \path -> do
            df <- D.fastReadCsv path
            expectColumn "bool column" "b" df (DI.fromList @Bool [True, False])
            expectColumn
                "date column"
                "d"
                df
                (DI.fromList [read @Day "2020-01-01", read "2021-12-31"])

-- Preserved: a null inside a date column yields Maybe Day.
testDateWithNulls :: Test
testDateWithNulls = TestLabel "typed_date_nulls" $
    TestCase $
        withCsv "date_nulls" "d,x\n2020-01-01,a\n,b\n2021-12-31,c\n" $ \path -> do
            df <- D.fastReadCsv path
            expectColumn
                "nullable date column"
                "d"
                df
                ( DI.fromList
                    [ Just (read @Day "2020-01-01")
                    , Nothing
                    , Just (read "2021-12-31")
                    ]
                )

-- The scalar tail (last <64 bytes after the SIMD chunks) must honour the
-- quote state carried out of the SIMD region: a quoted field that opens
-- before the 64-byte boundary and closes inside the tail may contain
-- separators and newlines.
testQuoteSpansChunkBoundary :: Test
testQuoteSpansChunkBoundary = TestLabel "typed_quote_spans_boundary" $
    TestCase $ do
        let pad = T.replicate 70 "x"
            body = "a,b\n" <> pad <> ",\"with,comma\nand newline\"\n"
        withCsv "boundary_quote" body $ \path -> do
            df <- D.fastReadCsv path
            assertEqual "one data row" 1 (fst (dataframeDimensions df))
            expectColumn
                "quoted field spanning the SIMD/tail boundary"
                "b"
                df
                (DI.fromList @T.Text ["with,comma\nand newline"])

-- An unclosed quote that opens inside the scalar tail must still raise.
testUnclosedQuoteInTail :: Test
testUnclosedQuoteInTail = TestLabel "typed_unclosed_in_tail" $
    TestCase $ do
        let pad = T.replicate 70 "x"
            body = "a\n" <> pad <> "\n\"dangling"
        withCsv "tail_unclosed" body $ \path -> do
            result <- try @D.CsvParseError (D.fastReadCsv path)
            case result of
                Left D.CsvUnclosedQuote -> pure ()
                other ->
                    assertFailure ("expected CsvUnclosedQuote, got " <> show other)

-- S2 pin: inference must land on exactly the same columns (types and
-- values) as an explicit schema declaring the true types.
testInferredMatchesSchema :: Test
testInferredMatchesSchema = TestLabel "typed_inferred_matches_schema" $
    TestCase $ do
        let rows =
                T.unlines
                    [ T.intercalate
                        ","
                        [ T.pack (show i)
                        , T.pack (show (fromIntegral i + 0.25 :: Double))
                        , "name" <> T.pack (show i)
                        , if even i then "True" else "false"
                        , "2024-01-0" <> T.pack (show (1 + i `mod` 9))
                        ]
                    | i <- [1 .. 150 :: Int]
                    ]
            body = "i,d,t,b,dt\n" <> rows
            schema =
                Schema
                    ( M.fromList
                        [ ("i", SType (P.Proxy @Int))
                        , ("d", SType (P.Proxy @Double))
                        , ("t", SType (P.Proxy @T.Text))
                        , ("b", SType (P.Proxy @Bool))
                        , ("dt", SType (P.Proxy @Day))
                        ]
                    )
        withCsv "inferred_vs_schema" body $ \path -> do
            inferred <- D.fastReadCsv path
            explicit <- D.fastReadCsvWithSchema schema path
            assertEqual "inferred == explicit schema" explicit inferred

-- Ingest pin: an inferred string column freezes to a shared-buffer
-- 'PackedText' (no per-row boxed Text header), values byte-identical.
testTextFreezesPacked :: Test
testTextFreezesPacked = TestLabel "typed_text_freezes_packed" $
    TestCase $
        withCsv "freezes_packed" "a,b\n1,x\n2,y\n" $ \path -> do
            df <- D.fastReadCsv path
            case getColumn "b" df of
                Nothing -> assertFailure "missing text column"
                Just col -> do
                    assertBool "text column is PackedText" (DI.isPackedText col)
                    assertEqual "values byte-identical" (DI.fromList @T.Text ["x", "y"]) col

-- The in-memory entry point must agree with the file-based one.
testFromBytesMatchesFile :: Test
testFromBytesMatchesFile = TestLabel "typed_from_bytes" $
    TestCase $ do
        let body = "a,b,c\n1,x,2.5\n2,y,3.5\n,z,\n"
        withCsv "from_bytes" body $ \path -> do
            fromFile <- D.fastReadCsv path
            fromBytes <- D.fastReadCsvFromBytes (TE.encodeUtf8 body)
            assertEqual "file and bytes parses agree" fromFile fromBytes

tests :: [Test]
tests =
    [ testIntOverflowDemotesToDouble
    , testPaddedDoubleStaysDouble
    , testSchemaTextBypassesInference
    , testSchemaStrictFailureThrows
    , testSchemaMaybeReadNullsFailures
    , testNoSafeReadNADemotesToText
    , testMaybeReadMissingTokens
    , testMaybeReadCustomMissingToken
    , testQuotedNumbersParse
    , testBoolAndDateInference
    , testDateWithNulls
    , testQuoteSpansChunkBoundary
    , testUnclosedQuoteInTail
    , testInferredMatchesSchema
    , testTextFreezesPacked
    , testFromBytesMatchesFile
    ]