packages feed

dataframe-2.1.0.2: tests/Operations/Record.hs

{-# LANGUAGE DataKinds #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE OverloadedLabels #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TemplateHaskell #-}
{-# LANGUAGE TypeApplications #-}
{-# LANGUAGE TypeFamilies #-}
{-# LANGUAGE TypeOperators #-}
{-# LANGUAGE UndecidableInstances #-}

module Operations.Record where

import Data.Int (Int64)
import qualified Data.Map.Strict as M
import qualified Data.Text as T
import qualified Data.Text.IO as TIO
import GHC.Generics (Generic)

import qualified DataFrame as D
import qualified DataFrame.Functions as F
import qualified DataFrame.Internal.Column as DI
import qualified DataFrame.Internal.Schema as IS
import DataFrame.Operators
import DataFrame.Typed (Schema)
import qualified DataFrame.Typed as DT

import Test.HUnit

-- Basic record with three different-type fields (Int64 / Text / Double).
data Order = Order
    { orderId :: Int64
    , region :: T.Text
    , amount :: Double
    }
    deriving (Show, Eq)

$(DT.deriveSchemaFromType ''Order)
$(D.deriveSchema ''Order)

-- Nullable fields (Maybe Text -> RNullableBoxed; Maybe Int -> RNullableUnboxed).
data User = User
    { userId :: Int64
    , userName :: Maybe T.Text
    , userAge :: Maybe Int
    }
    deriving (Show, Eq)

$(DT.deriveSchemaFromType ''User)
$(D.deriveSchema ''User)

-- Identity-cased: keep the record selector names verbatim.
data Account = Account
    { accountId :: Int64
    , accountName :: T.Text
    }
    deriving (Show, Eq)

$( DT.deriveSchemaFromTypeWith
    DT.defaultSchemaOptions{DT.nameTransform = id}
    ''Account
 )

-- Schema-name override.
data Item = Item
    { itemId :: Int64
    , itemPrice :: Double
    }
    deriving (Show, Eq)

$( DT.deriveSchemaFromTypeWith
    DT.defaultSchemaOptions{DT.schemaTypeName = Just "ItemsSchema"}
    ''Item
 )

-- Wide record (more than zipWith3 can handle).
data Wide = Wide
    { f1 :: Int
    , f2 :: Int
    , f3 :: Int
    , f4 :: Int
    , f5 :: Int
    , f6 :: Int
    , f7 :: Int
    , f8 :: Int
    }
    deriving (Show, Eq)

$(DT.deriveSchemaFromType ''Wide)
$(D.deriveSchema ''Wide)

-- Generics opt-in: derive the schema via Generic, not TH.
data Foo = Foo
    { fooId :: Int64
    , fooName :: T.Text
    , fooValue :: Double
    }
    deriving (Show, Eq, Generic)

type FooSchema = DT.SchemaOf Foo

instance DT.HasSchema Foo where
    type Schema Foo = FooSchema
    toColumns = DT.genericToColumns
    fromColumns = DT.genericFromColumns

orderSample :: [Order]
orderSample =
    [ Order 1 "us" 10.0
    , Order 2 "eu" 20.5
    , Order 3 "ap" 30.0
    ]

basicTypedRoundTrip :: Test
basicTypedRoundTrip = TestCase $ do
    let df :: DT.TypedDataFrame OrderSchema
        df = DT.fromRecordsTyped orderSample
    case DT.toRecordsTyped df of
        Left e -> assertFailure (T.unpack e)
        Right xs -> assertEqual "typed round-trip" orderSample xs

basicUntypedRoundTrip :: Test
basicUntypedRoundTrip = TestCase $ do
    let df = D.fromRecords orderSample
    case D.toRecords df :: Either T.Text [Order] of
        Left e -> assertFailure (T.unpack e)
        Right xs -> assertEqual "untyped round-trip" orderSample xs

emptyRoundTrip :: Test
emptyRoundTrip = TestCase $ do
    let df = D.fromRecords ([] :: [Order])
    case D.toRecords df :: Either T.Text [Order] of
        Left e -> assertFailure (T.unpack e)
        Right xs -> assertEqual "empty round-trip" [] xs

nullableRoundTrip :: Test
nullableRoundTrip = TestCase $ do
    let xs =
            [ User 1 (Just "alice") (Just 30)
            , User 2 Nothing (Just 40)
            , User 3 (Just "carol") Nothing
            , User 4 Nothing Nothing
            ]
        df = D.fromRecords xs
    case D.toRecords df :: Either T.Text [User] of
        Left e -> assertFailure (T.unpack e)
        Right ys -> assertEqual "nullable round-trip" xs ys

identityRoundTrip :: Test
identityRoundTrip = TestCase $ do
    let xs = [Account 1 "alice", Account 2 "bob"]
        df = D.fromRecords xs
    assertEqual
        "identity column names"
        ["accountId", "accountName"]
        (D.columnNames df)
    case D.toRecords df :: Either T.Text [Account] of
        Left e -> assertFailure (T.unpack e)
        Right ys -> assertEqual "identity round-trip" xs ys

schemaNameOverride :: Test
schemaNameOverride = TestCase $ do
    let xs = [Item 1 9.99, Item 2 19.99]
        df :: DT.TypedDataFrame ItemsSchema
        df = DT.fromRecordsTyped xs
    case DT.toRecordsTyped df of
        Left e -> assertFailure (T.unpack e)
        Right ys -> assertEqual "schema-name override round-trip" xs ys

typeMismatchError :: Test
typeMismatchError = TestCase $ do
    let badDf =
            D.fromNamedColumns
                [ ("order_id", DI.fromList ([1, 2, 3] :: [Int])) -- wrong: Int, not Int64
                , ("region", DI.fromList (["us", "eu", "ap"] :: [T.Text]))
                , ("amount", DI.fromList ([10.0, 20.5, 30.0] :: [Double]))
                ]
    case D.toRecords badDf :: Either T.Text [Order] of
        Right _ -> assertFailure "expected error on type mismatch"
        Left e ->
            assertBool
                ("error should mention order_id, got: " ++ T.unpack e)
                ("order_id" `T.isInfixOf` e)

missingColumnError :: Test
missingColumnError = TestCase $ do
    let badDf =
            D.fromNamedColumns
                [ ("region", DI.fromList (["us"] :: [T.Text]))
                , ("amount", DI.fromList ([10.0] :: [Double]))
                ]
    case D.toRecords badDf :: Either T.Text [Order] of
        Right _ -> assertFailure "expected error on missing column"
        Left e ->
            assertBool
                ("error should mention order_id, got: " ++ T.unpack e)
                ("order_id" `T.isInfixOf` e)

wideRoundTrip :: Test
wideRoundTrip = TestCase $ do
    let xs =
            [ Wide 1 2 3 4 5 6 7 8
            , Wide 9 10 11 12 13 14 15 16
            ]
        df = D.fromRecords xs
    case D.toRecords df :: Either T.Text [Wide] of
        Left e -> assertFailure (T.unpack e)
        Right ys -> assertEqual "wide round-trip" xs ys

genericRoundTrip :: Test
genericRoundTrip = TestCase $ do
    let xs = [Foo 1 "a" 1.0, Foo 2 "b" 2.5]
        df = D.fromRecords xs
    case D.toRecords df :: Either T.Text [Foo] of
        Left e -> assertFailure (T.unpack e)
        Right ys -> assertEqual "generic round-trip" xs ys

genericColumnNames :: Test
genericColumnNames = TestCase $ do
    let df = D.fromRecords [Foo 1 "a" 1.0]
    assertEqual
        "generic snake-cased column names"
        ["foo_id", "foo_name", "foo_value"]
        (D.columnNames df)

deriveSchemaSplice :: Test
deriveSchemaSplice = TestCase $ do
    assertEqual
        "orderSchema column names"
        ["amount", "order_id", "region"]
        (M.keys (IS.elements orderSchema))
    assertEqual
        "order_id is Int64"
        (Just (IS.schemaType @Int64))
        (M.lookup "order_id" (IS.elements orderSchema))
    assertEqual
        "region is Text"
        (Just (IS.schemaType @T.Text))
        (M.lookup "region" (IS.elements orderSchema))
    assertEqual
        "amount is Double"
        (Just (IS.schemaType @Double))
        (M.lookup "amount" (IS.elements orderSchema))

deriveSchemaNullable :: Test
deriveSchemaNullable = TestCase $ do
    assertEqual
        "userSchema column names"
        ["user_age", "user_id", "user_name"]
        (M.keys (IS.elements userSchema))
    assertEqual
        "user_id is Int64"
        (Just (IS.schemaType @Int64))
        (M.lookup "user_id" (IS.elements userSchema))
    assertEqual
        "user_name is Maybe Text"
        (Just (IS.schemaType @(Maybe T.Text)))
        (M.lookup "user_name" (IS.elements userSchema))
    assertEqual
        "user_age is Maybe Int"
        (Just (IS.schemaType @(Maybe Int)))
        (M.lookup "user_age" (IS.elements userSchema))

deriveSchemaWide :: Test
deriveSchemaWide = TestCase $ do
    assertEqual
        "wideSchema has 8 keys"
        8
        (M.size (IS.elements wideSchema))
    assertEqual
        "f1 is Int"
        (Just (IS.schemaType @Int))
        (M.lookup "f1" (IS.elements wideSchema))
    assertEqual
        "f8 is Int"
        (Just (IS.schemaType @Int))
        (M.lookup "f8" (IS.elements wideSchema))

deriveSchemaReadsCsv :: Test
deriveSchemaReadsCsv = TestCase $ do
    let csv =
            T.unlines
                [ "order_id,region,amount"
                , "1,us,10.0"
                , "2,eu,20.5"
                , "3,ap,30.0"
                ]
        tmp = "/tmp/dataframe_test_deriveSchema.csv"
    TIO.writeFile tmp csv
    df <- D.readCsvWithSchema orderSchema tmp
    assertEqual
        "deriveSchema-driven readCsvWithSchema column names"
        ["order_id", "region", "amount"]
        (D.columnNames df)
    case D.toRecords df :: Either T.Text [Order] of
        Left e -> assertFailure (T.unpack e)
        Right xs ->
            assertEqual
                "deriveSchema-driven CSV parses back to records"
                [Order 1 "us" 10.0, Order 2 "eu" 20.5, Order 3 "ap" 30.0]
                xs

deriveSchemaAccessorFilter :: Test
deriveSchemaAccessorFilter = TestCase $ do
    let df =
            D.fromRecords
                [ Order 1 "us" 20.0
                , Order 2 "eu" 20.5
                , Order 3 "ap" 30.0
                , Order 4 "us" 25.0
                ]
        big =
            D.filterWhere
                (orderAmount .>. F.lit @Double 15.0 .&&. orderRegion .==. F.lit @T.Text "us")
                df
    case D.toRecords big :: Either T.Text [Order] of
        Left e -> assertFailure (T.unpack e)
        Right xs ->
            assertEqual
                "accessor drives D.filter (amount > 15.0 && region == \"us\")"
                [Order 1 "us" 20.0, Order 4 "us" 25.0]
                xs

deriveSchemaAccessorDerive :: Test
deriveSchemaAccessorDerive = TestCase $ do
    let df =
            D.fromRecords
                [ Order 1 "us" 10.0
                , Order 2 "eu" 20.0
                ]
        df' = D.derive "double_amount" (orderAmount + orderAmount) df
    assertEqual
        "accessor composes in derive expression"
        [20.0, 40.0]
        (D.columnAsList (D.col @Double "double_amount") df')

labelColumnFilter :: Test
labelColumnFilter = TestCase $ do
    let df :: DT.TypedDataFrame OrderSchema
        df = DT.fromRecordsTyped orderSample
        usOnly = DT.filterWhere (#region DT..==. "us") df
    case DT.toRecordsTyped usOnly of
        Left e -> assertFailure (T.unpack e)
        Right xs ->
            assertEqual
                "#region OverloadedLabel resolves to col @\"region\""
                [Order 1 "us" 10.0]
                xs

tests :: [Test]
tests =
    [ TestLabel "basicTypedRoundTrip" basicTypedRoundTrip
    , TestLabel "labelColumnFilter" labelColumnFilter
    , TestLabel "basicUntypedRoundTrip" basicUntypedRoundTrip
    , TestLabel "emptyRoundTrip" emptyRoundTrip
    , TestLabel "nullableRoundTrip" nullableRoundTrip
    , TestLabel "identityRoundTrip" identityRoundTrip
    , TestLabel "schemaNameOverride" schemaNameOverride
    , TestLabel "typeMismatchError" typeMismatchError
    , TestLabel "missingColumnError" missingColumnError
    , TestLabel "wideRoundTrip" wideRoundTrip
    , TestLabel "genericRoundTrip" genericRoundTrip
    , TestLabel "genericColumnNames" genericColumnNames
    , TestLabel "deriveSchemaSplice" deriveSchemaSplice
    , TestLabel "deriveSchemaNullable" deriveSchemaNullable
    , TestLabel "deriveSchemaWide" deriveSchemaWide
    , TestLabel "deriveSchemaReadsCsv" deriveSchemaReadsCsv
    , TestLabel "deriveSchemaAccessorFilter" deriveSchemaAccessorFilter
    , TestLabel "deriveSchemaAccessorDerive" deriveSchemaAccessorDerive
    ]