packages feed

dataframe-3.2.0.0: tests/LazyProjection.hs

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

{- | A lazy scan reads only the columns its 'Schema' names, and rejects
self-contradictory 'ReadOptions' before opening the file.
-}
module LazyProjection (tests) where

import qualified Data.Map.Strict as M
import qualified Data.Text as T
import qualified Data.Text.IO as TIO

import Control.Exception (SomeException, evaluate, try)
import Data.List (isInfixOf)
import qualified DataFrame as D
import qualified DataFrame.IO.CSV as Csv
import qualified DataFrame.IO.CSV.Fast as Fast
import DataFrame.Internal.DataFrame (forceDataFrame)
import qualified DataFrame.Lazy as L
import DataFrame.Schema (Schema (..), schemaType)
import System.Directory (removeFile)
import System.IO.Temp (emptySystemTempFile)
import Test.HUnit

-- | Four columns; every schema below names at most two of them.
withCsv :: Char -> (FilePath -> IO a) -> IO a
withCsv sep k = do
    csvPath <- emptySystemTempFile "lazy_projection_.csv"
    TIO.writeFile csvPath (T.unlines (map (T.intercalate (T.singleton sep)) rows))
    r <- k csvPath
    removeFile csvPath
    pure r
  where
    rows =
        [ ["id", "name", "surname", "dob"]
        , ["1", "Ada", "Lovelace", "1815"]
        , ["2", "Alan", "Turing", "1912"]
        ]

twoCols :: Schema
twoCols =
    Schema $ M.fromList [("id", schemaType @Int), ("dob", schemaType @Int)]

{- | The scan keeps only the schema's columns — and 'dob' is not a prefix of
the header, so a positional scheme could not express this.
-}
scanProjects :: Test
scanProjects = TestLabel "scan_projects" $ TestCase $ withCsv ',' $ \csvPath -> do
    df <- L.runDataFrame (L.scanCsv twoCols (T.pack csvPath))
    assertEqual "dims" (2, 2) (D.dimensions df)
    assertEqual "columns" ["dob", "id"] (D.columnNames df)

-- | Same schema, same projection, through the SIMD reader.
scanProjectsWithFastReader :: Test
scanProjectsWithFastReader =
    TestLabel "scan_projects_fast_reader" $ TestCase $ withCsv ',' $ \csvPath -> do
        df <-
            L.runDataFrame
                (L.scanCsvWith Fast.fastReadCsvWithOpts twoCols (T.pack csvPath))
        assertEqual "dims" (2, 2) (D.dimensions df)
        assertEqual "columns" ["dob", "id"] (D.columnNames df)

-- | Both readers must agree on what a scan produces.
readersAgree :: Test
readersAgree = TestLabel "scan_readers_agree" $ TestCase $ withCsv ',' $ \csvPath -> do
    slow <- L.runDataFrame (L.scanCsv twoCols (T.pack csvPath))
    fast <-
        L.runDataFrame (L.scanCsvWith Fast.fastReadCsvWithOpts twoCols (T.pack csvPath))
    assertEqual "same frame" slow fast

{- | 'scanSeparated' used to drop its separator on the floor: the reader was
built from the schema alone and always split on commas.
-}
scanHonoursSeparator :: Test
scanHonoursSeparator =
    TestLabel "scan_honours_separator" $ TestCase $ withCsv ';' $ \csvPath -> do
        -- With the separator ignored, the whole line is one field and the
        -- schema's columns would not be found at all.
        df <- L.runDataFrame (L.scanSeparated ';' twoCols (T.pack csvPath))
        assertEqual "dims" (2, 2) (D.dimensions df)
        assertEqual "columns" ["dob", "id"] (D.columnNames df)

-- | A schema naming a column the file lacks is an error, not a short frame.
scanMissingColumn :: Test
scanMissingColumn = TestLabel "scan_missing_column" $ TestCase $ withCsv ',' $ \csvPath -> do
    let bogus = Schema $ M.fromList [("nope", schemaType @Int)]
    r <-
        try
            (L.runDataFrame (L.scanCsv bogus (T.pack csvPath)) >>= evaluate . forceDataFrame)
    case r of
        Left (e :: SomeException) ->
            assertBool
                ("expected Column not found, got " <> show e)
                ("Column not found" `isInfixOf` show e)
        Right _ -> assertFailure "expected a missing-column error"

-- | Contradictory options are rejected before the file is opened.
invalidOptionsCase :: String -> Csv.ReadOptions -> String -> Test
invalidOptionsCase label opts needle = TestLabel label $ TestCase $ do
    let expectFail which act = do
            r <- try (act >>= evaluate . forceDataFrame)
            case r of
                Left (e :: SomeException) ->
                    assertBool
                        ( label
                            <> ": "
                            <> which
                            <> " should mention "
                            <> show needle
                            <> ", got "
                            <> show e
                        )
                        (needle `isInfixOf` show e)
                Right _ -> assertFailure (label <> ": " <> which <> " should have failed")
    -- "no-such-file" proves the failure precedes any read.
    expectFail "pure reader" (Csv.readSeparated opts "no-such-file.csv")
    expectFail "fast reader" (Fast.fastReadCsvWithOpts opts "no-such-file.csv")

invalidOptions :: [Test]
invalidOptions =
    [ invalidOptionsCase
        "reject_empty_selection"
        Csv.defaultReadOptions{Csv.readColumns = Just []}
        "empty selection"
    , invalidOptionsCase
        "reject_duplicate_selection"
        Csv.defaultReadOptions{Csv.readColumns = Just ["a", "b", "a"]}
        "more than once"
    , invalidOptionsCase
        "reject_negative_row_cap"
        Csv.defaultReadOptions{Csv.numRowsToRead = Just (-1)}
        "numRowsToRead is negative"
    , invalidOptionsCase
        "reject_nameless_providenames"
        Csv.defaultReadOptions{Csv.headerSpec = Csv.ProvideNames []}
        "ProvideNames with no names"
    , invalidOptionsCase
        "reject_quote_separator"
        Csv.defaultReadOptions{Csv.columnSeparator = '"'}
        "cannot delimit fields"
    ]

{- | The SIMD reader's low-level entry takes its separator as a scanner byte,
so a 'columnSeparator' saying something else would be silently ignored.
Reject it instead.
-}
rejectSeparatorMismatch :: Test
rejectSeparatorMismatch = TestLabel "reject_separator_mismatch" $ TestCase $ do
    let opts = Csv.defaultReadOptions{Csv.columnSeparator = ';'}
        htab = 0x09
    r <- try (Fast.readSeparated htab opts "no-such-file.csv")
    case r of
        Left (e :: SomeException) ->
            assertBool
                ("expected a separator complaint, got " <> show e)
                ("columnSeparator" `isInfixOf` show e)
        Right (_ :: D.DataFrame) -> assertFailure "expected a separator mismatch error"

tests :: [Test]
tests =
    [ scanProjects
    , scanProjectsWithFastReader
    , readersAgree
    , scanHonoursSeparator
    , scanMissingColumn
    , rejectSeparatorMismatch
    ]
        <> invalidOptions