dataframe-2.3.0.0: tests/Internal/ColumnBuilder.hs
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TypeApplications #-}
module Internal.ColumnBuilder (tests, props) where
import qualified Data.ByteString as B
import qualified Data.ByteString.Unsafe as BU
import qualified Data.Text as T
import qualified Data.Text.Array as A
import qualified Data.Vector as VB
import qualified Data.Vector.Unboxed as VU
import Control.Monad (forM_, zipWithM_)
import Control.Monad.ST (runST, stToIO)
import Data.Maybe (catMaybes, isNothing)
import Data.Text.Encoding (decodeUtf8Lenient, encodeUtf8)
import Data.Type.Equality (testEquality, (:~:) (Refl))
import Data.Word (Word8)
import DataFrame.Internal.Column hiding (mergeColumns)
import DataFrame.Internal.ColumnBuilder
import Foreign.Ptr (castPtr)
import Test.HUnit
import Test.QuickCheck
import Type.Reflection (typeRep)
-- Build a column by appending each list element (Nothing => appendNull).
buildIntColumn :: [Maybe Int] -> Column
buildIntColumn xs = runST $ do
b <- newIntBuilder (length xs)
mapM_ (maybe (appendNull b) (appendInt b)) xs
freezeBuilder b
buildDoubleColumn :: [Maybe Double] -> Column
buildDoubleColumn xs = runST $ do
b <- newDoubleBuilder (length xs)
mapM_ (maybe (appendNull b) (appendDouble b)) xs
freezeBuilder b
buildTextColumn :: [Maybe T.Text] -> Column
buildTextColumn xs = runST $ do
b <- newTextBuilder (length xs) 16
mapM_ (maybe (appendNull b) (appendText b)) xs
freezeBuilder b
-- Build a text column from raw byte fields via appendTextSlice.
buildTextColumnFromBytes :: [[Word8]] -> Column
buildTextColumnFromBytes fields = runST $ do
b <- newTextBuilder (length fields) 16
forM_ fields $ \ws ->
appendTextSlice b (arrayFromBytes ws) 0 (length ws)
freezeBuilder b
arrayFromBytes :: [Word8] -> A.Array
arrayFromBytes ws = A.run $ do
m <- A.new (length ws)
zipWithM_ (A.unsafeWrite m) [0 ..] ws
pure m
-- Reference column: fromVector of the plain list (no nulls) or Maybe list.
expectedColumn ::
forall a.
(Columnable a, Columnable (Maybe a)) =>
[Maybe a] -> Column
expectedColumn xs
| any isNothing xs = fromVector (VB.fromList xs)
| otherwise = fromVector (VB.fromList (catMaybes xs))
-- Read a text column back as a Maybe list, respecting the bitmap.
-- Ingest now freezes text to 'PackedText'; materialize to the boxed form.
columnTexts :: Column -> [Maybe T.Text]
columnTexts c@(PackedText _ _) = columnTexts (materializePacked c)
columnTexts (BoxedColumn mb (v :: VB.Vector b)) =
case testEquality (typeRep @b) (typeRep @T.Text) of
Just Refl ->
[ if maybe True (`bitmapTestBit` i) mb
then Just (v VB.! i)
else Nothing
| i <- [0 .. VB.length v - 1]
]
Nothing -> error "columnTexts: not a Text column"
columnTexts _ = error "columnTexts: not a boxed column"
-- Split a list into arbitrary contiguous chunks (at least one chunk).
splitsOf :: [a] -> Gen [[a]]
splitsOf [] = pure [[]]
splitsOf xs = go xs
where
go [] = pure []
go ys = do
k <- chooseInt (1, length ys)
let (a, b) = splitAt k ys
(a :) <$> go b
nullableVersion :: [Maybe a] -> String -> String -> String
nullableVersion xs nullable plain =
if any isNothing xs then nullable else plain
-- HUnit tests
intAllValidHasNoBitmap :: Test
intAllValidHasNoBitmap =
TestCase $
assertEqual
"all-valid int column is Unboxed with no bitmap"
"Unboxed"
(columnVersionString (buildIntColumn (map Just [1, 2, 3])))
intWithNullHasBitmap :: Test
intWithNullHasBitmap =
TestCase $
assertEqual
"int column with null is NullableUnboxed"
"NullableUnboxed"
(columnVersionString (buildIntColumn [Just 1, Nothing]))
intNullSentinelIsZero :: Test
intNullSentinelIsZero = TestCase $
case buildIntColumn [Just 5, Nothing, Just 7] of
UnboxedColumn (Just _) (v :: VU.Vector b) ->
case testEquality (typeRep @b) (typeRep @Int) of
Just Refl -> assertEqual "null slot holds 0" 0 (v VU.! 1)
Nothing -> assertFailure "expected an Int column"
_ -> assertFailure "expected a nullable unboxed column"
textAllValidHasNoBitmap :: Test
textAllValidHasNoBitmap =
TestCase $
assertEqual
"all-valid text column is Boxed with no bitmap"
"Boxed"
(columnVersionString (buildTextColumn [Just "a", Just "bc"]))
builderLengthCounts :: Test
builderLengthCounts = TestCase $ do
let n = runST $ do
b <- newIntBuilder 4
appendInt b 1
appendNull b
appendInt b 3
builderLength b
assertEqual "builderLength counts nulls and values" 3 n
growthPastCapacityHint :: Test
growthPastCapacityHint = TestCase $ do
let xs = map Just [0 .. 9999 :: Int]
col = runST $ do
b <- newIntBuilder 1
mapM_ (maybe (appendNull b) (appendInt b)) xs
freezeBuilder b
assertEqual "doubling growth preserves data" (expectedColumn xs) col
multibyteTextRoundTrip :: Test
multibyteTextRoundTrip = TestCase $ do
let ts = ["héllo", "wörld", "日本語", "😀😃", ""]
assertEqual
"multibyte text round trips"
(map Just ts)
(columnTexts (buildTextColumn (map Just ts)))
-- A multibyte sequence split across two fields: the whole buffer is valid
-- UTF-8 but each field alone is not. Both must lenient-decode to U+FFFD.
splitMultibyteAcrossFields :: Test
splitMultibyteAcrossFields = TestCase $ do
let fields = [[0xC2], [0x80]]
assertEqual
"fields splitting a sequence decode leniently"
(map (Just . decodeUtf8Lenient . B.pack) fields)
(columnTexts (buildTextColumnFromBytes fields))
splitEuroAcrossFields :: Test
splitEuroAcrossFields = TestCase $ do
let fields = [[0xE2, 0x82], [0xAC, 0x61]]
assertEqual
"euro sign split across fields decodes leniently"
(map (Just . decodeUtf8Lenient . B.pack) fields)
(columnTexts (buildTextColumnFromBytes fields))
invalidUtf8EdgeCases :: Test
invalidUtf8EdgeCases = TestCase $ do
let fields =
[ [0xE2, 0x28, 0xA1]
, [0xF0, 0x9F, 0x98]
, [0xF0, 0x28]
, [0xC2]
, [0x80]
, [0xED, 0xA0, 0x80]
, [0xF4, 0x90, 0x80, 0x80]
, [0xC0, 0xAF]
, [0x61, 0xF1, 0x80, 0x80, 0xE1, 0x80, 0xC2, 0x62]
, [0xF0, 0x9F, 0x98, 0x80]
]
assertEqual
"invalid sequences match decodeUtf8Lenient"
(map (Just . decodeUtf8Lenient . B.pack) fields)
(columnTexts (buildTextColumnFromBytes fields))
appendFromPtrMatchesText :: Test
appendFromPtrMatchesText = TestCase $ do
let t = "héllo wörld 😀" :: T.Text
col <- BU.unsafeUseAsCStringLen (encodeUtf8 t) $ \(p, len) ->
stToIO $ do
b <- newTextBuilder 1 0
appendTextSliceFromPtr b (castPtr p) len
freezeBuilder b
assertEqual "ptr slice decodes to original text" [Just t] (columnTexts col)
mergeBitmapSpliceUnaligned :: Test
mergeBitmapSpliceUnaligned = TestCase $ do
let nullIdx = [1, 4, 9, 10, 14] :: [Int]
xs = [if i `elem` nullIdx then Nothing else Just i | i <- [0 .. 14]]
chunks = [take 3 xs, take 5 (drop 3 xs), drop 8 xs]
merged = mergeColumns (map buildIntColumn chunks)
assertEqual "merged equals unsplit" (buildIntColumn xs) merged
forM_ [0 .. 14] $ \i ->
assertEqual
("null at index " ++ show i)
(i `elem` nullIdx)
(columnElemIsNull merged i)
tests :: [Test]
tests =
[ TestLabel "intAllValidHasNoBitmap" intAllValidHasNoBitmap
, TestLabel "intWithNullHasBitmap" intWithNullHasBitmap
, TestLabel "intNullSentinelIsZero" intNullSentinelIsZero
, TestLabel "textAllValidHasNoBitmap" textAllValidHasNoBitmap
, TestLabel "builderLengthCounts" builderLengthCounts
, TestLabel "growthPastCapacityHint" growthPastCapacityHint
, TestLabel "multibyteTextRoundTrip" multibyteTextRoundTrip
, TestLabel "splitMultibyteAcrossFields" splitMultibyteAcrossFields
, TestLabel "splitEuroAcrossFields" splitEuroAcrossFields
, TestLabel "invalidUtf8EdgeCases" invalidUtf8EdgeCases
, TestLabel "appendFromPtrMatchesText" appendFromPtrMatchesText
, TestLabel "mergeBitmapSpliceUnaligned" mergeBitmapSpliceUnaligned
]
-- QuickCheck properties
prop_intMatchesFromVector :: [Maybe Int] -> Property
prop_intMatchesFromVector xs =
buildIntColumn xs === expectedColumn xs
.&&. columnVersionString (buildIntColumn xs)
=== nullableVersion xs "NullableUnboxed" "Unboxed"
prop_doubleMatchesFromVector :: [Maybe Double] -> Property
prop_doubleMatchesFromVector xs =
buildDoubleColumn xs === expectedColumn xs
prop_textMatchesFromVector :: [Maybe String] -> Property
prop_textMatchesFromVector ss =
let xs = map (fmap T.pack) ss
in buildTextColumn xs === expectedColumn xs
.&&. columnVersionString (buildTextColumn xs)
=== nullableVersion xs "NullableBoxed" "Boxed"
prop_textSliceMatchesLenientDecode :: [[Word8]] -> Property
prop_textSliceMatchesLenientDecode fields =
columnTexts (buildTextColumnFromBytes fields)
=== map (Just . decodeUtf8Lenient . B.pack) fields
prop_mergeIntMatchesUnsplit :: [Maybe Int] -> Property
prop_mergeIntMatchesUnsplit xs = forAll (splitsOf xs) $ \chunks ->
let merged = mergeColumns (map buildIntColumn chunks)
unsplit = buildIntColumn xs
in merged === unsplit
.&&. columnVersionString merged === columnVersionString unsplit
prop_mergeDoubleMatchesUnsplit :: [Maybe Double] -> Property
prop_mergeDoubleMatchesUnsplit xs = forAll (splitsOf xs) $ \chunks ->
mergeColumns (map buildDoubleColumn chunks) === buildDoubleColumn xs
prop_mergeTextMatchesUnsplit :: [Maybe String] -> Property
prop_mergeTextMatchesUnsplit ss =
let xs = map (fmap T.pack) ss
in forAll (splitsOf xs) $ \chunks ->
let merged = mergeColumns (map buildTextColumn chunks)
unsplit = buildTextColumn xs
in merged === unsplit
.&&. columnTexts merged === columnTexts unsplit
prop_mergeNullsLandAtRightRows :: [Maybe Int] -> Property
prop_mergeNullsLandAtRightRows xs = forAll (splitsOf xs) $ \chunks ->
let merged = mergeColumns (map buildIntColumn chunks)
in map isNothing xs
=== [columnElemIsNull merged i | i <- [0 .. length xs - 1]]
props :: [Property]
props =
[ property prop_intMatchesFromVector
, property prop_doubleMatchesFromVector
, property prop_textMatchesFromVector
, property prop_textSliceMatchesLenientDecode
, property prop_mergeIntMatchesUnsplit
, property prop_mergeDoubleMatchesUnsplit
, property prop_mergeTextMatchesUnsplit
, property prop_mergeNullsLandAtRightRows
]