packages feed

wireform-arrow-0.1.0.0: test/Test/Arrow/Decimal.hs

{-# LANGUAGE OverloadedStrings #-}

module Test.Arrow.Decimal (tests) where

import Arrow.Column qualified as C
import Arrow.File qualified as F
import Arrow.FlatBufferIPC qualified as IPC
import Arrow.Read.Columns (decodeRecordBatch, defaultReadOptions)
import Arrow.Stream qualified as S
import Arrow.Types
import Arrow.Write.Columns (validateColumns)
import Control.Monad (forM_, unless)
import Data.ByteString qualified as BS
import Data.ByteString.Lazy qualified as BL
import Data.Either (isLeft)
import Data.Int (Int32)
import Data.Vector qualified as V
import Data.Vector.Storable qualified as VS


data DecimalCase = DecimalCase
  { decimalType :: !ArrowType
  , decimalColumn :: !C.ColumnArray
  , decimalWidth :: !Int
  }


cases :: [DecimalCase]
cases =
  [ DecimalCase (ADecimal32 9 2) (C.fromMaybes (C.PDecimal32 9 2) (V.fromList [Just (-999999999), Nothing, Just 0, Just 123, Just 999999999])) 4
  , DecimalCase (ADecimal64 18 2) (C.fromMaybes (C.PDecimal64 18 2) (V.fromList [Just (-999999999999999999), Nothing, Just 0, Just 123, Just 999999999999999999])) 8
  , DecimalCase (ADecimal 38 2) (C.fromMaybes (C.PDecimal128 38 2) (V.fromList (map (fmap C.decimal128FromInteger) [Just (negate (10 ^ (38 :: Int) - 1)), Nothing, Just 0, Just 123, Just (10 ^ (38 :: Int) - 1)]))) 16
  , DecimalCase (ADecimal256 76 2) (C.fromMaybes (C.PDecimal256 76 2) (V.fromList (map (fmap C.decimal256FromInteger) [Just (negate (10 ^ (76 :: Int) - 1)), Nothing, Just 0, Just 123, Just (10 ^ (76 :: Int) - 1)]))) 32
  ]


load :: Either String a -> IO a
load = either fail pure


check :: String -> Bool -> IO ()
check name condition = unless condition (fail ("Arrow decimal regression: " ++ name))


tests :: IO ()
tests = do
  forM_ cases $ \sample -> do
    let column = decimalColumn sample
        field = defaultLeafField "d" True (decimalType sample)
        schema = defaultSchema (V.singleton field)
    roundtrip field column
    roundtrip field (C.sliceColumnArray 1 3 column)
    structure <- load (C.mkStruct 5 Nothing (V.singleton ("d", column)))
    roundtrip (defaultField "s" True AStruct (V.singleton field)) structure
    list <- load (C.mkList (C.validityFromBools (V.fromList [True, False, True])) (VS.fromList [0, 2, 2, 5]) column)
    roundtrip (defaultField "l" True AList (V.singleton field)) list
    dictionary <- load (C.mkDictionary 42 (C.fromMaybes C.PInt8 (V.fromList [Just 0, Nothing, Just 4, Just 1])) column)
    let dictField = field {fieldDictionary = Just (DictionaryEncoding 42 (AInt 8 True) False)}
    encodedDictionary <- load (S.encodeArrowStream S.defaultWriteOptions (defaultSchema (V.singleton dictField)) [V.singleton dictionary])
    (_, dictBatches) <- load (S.decodeArrowStream defaultReadOptions encodedDictionary)
    expanded <- load (C.expandDictionary dictionary)
    actualExpanded <- load (C.expandDictionary (V.head (head dictBatches)))
    check "dictionary values retain decimal width/parameters/nulls" (actualExpanded == expanded)
    bytes <- load (S.encodeArrowStream S.defaultWriteOptions schema [V.singleton column])
    stream <- load (F.readArrowStream bytes)
    let (batch, body) = V.head (F.asBatches stream)
        Buffer off size = rbBuffers batch VS.! 1
        width = decimalWidth sample
        begin = fromIntegral off
        sizeInt = fromIntegral size
        swapped = BS.concat (map (\i -> BS.reverse (BS.take width (BS.drop (begin + i * width) body))) [0 .. C.columnLength column - 1])
        bigBody = BS.take begin body <> swapped <> BS.drop (begin + sizeInt) body
    check "wire buffer uses its declared physical width" (sizeInt == C.columnLength column * width)
    big <- load (decodeRecordBatch defaultReadOptions schema {arrowEndianness = Big} batch bigBody)
    check "big-endian signed decimals retain every bit" (V.head big == column)
    let short = batch {rbBuffers = rbBuffers batch VS.// [(1, Buffer off (size - 1))]}
    check "short data buffers reject using the actual decimal width" (isLeft (decodeRecordBatch defaultReadOptions schema short body))
  let widths = [(ADecimal32, 9), (ADecimal64, 18), (ADecimal, 38), (ADecimal256, 76)]
  forM_ widths $ \(make, maximumPrecision) -> do
    forM_ [-1, 0, maximumPrecision + 1, maxBound] $ \precision -> do
      let field = defaultLeafField "bad" True (make precision 0)
          schema = defaultSchema (V.singleton field)
          nested = defaultSchema (V.singleton (defaultField "parent" True AStruct (V.singleton field)))
      check "reader rejects invalid decimal precision before materialization" (isLeft (IPC.decodeSchemaMessage (IPC.buildSchemaMessage schema)))
      check "file/footer reader rejects invalid decimal precision" (isLeft (IPC.readFileIndex (IPC.writeArrowFileFBWithDicts schema [] [])))
      forM_ [schema, nested] $ \invalid -> do
        check "schema-only stream writer rejects invalid precision" (isLeft (S.encodeArrowStream S.defaultWriteOptions invalid []))
        check "schema-only lazy stream writer rejects invalid precision" (isLeft (S.encodeArrowStreamLazy S.defaultWriteOptions invalid []))
        check "schema-only file writer rejects invalid precision" (isLeft (S.encodeArrowFile S.defaultWriteOptions invalid []))
        check "schema-only lazy file writer rejects invalid precision" (isLeft (S.encodeArrowFileLazy S.defaultWriteOptions invalid []))
    forM_ [-2, maximumPrecision + 3, fromIntegral (minBound :: Int32), fromIntegral (maxBound :: Int32)] $ \scale -> do
      let schema = defaultSchema (V.singleton (defaultLeafField "valid" True (make maximumPrecision scale)))
      metadata <- load (IPC.decodeSchemaMessage (IPC.buildSchemaMessage schema))
      check "negative/large signed32 scales are preserved" (metadata == schema)
      _ <- load (S.encodeArrowStream S.defaultWriteOptions schema [])
      pure ()
    forM_ [toInteger (minBound :: Int32) - 1, toInteger (maxBound :: Int32) + 1, 4294967298] $ \scale -> do
      let schema = defaultSchema (V.singleton (defaultLeafField "bad" True (make maximumPrecision (fromInteger scale))))
      check "scale cannot silently wrap when serialized" (isLeft (S.encodeArrowStream S.defaultWriteOptions schema []) && isLeft (S.encodeArrowFile S.defaultWriteOptions schema []))
  check "physical integers cannot masquerade as decimal columns" (isLeft (validateColumns (V.singleton (defaultLeafField "d" False (ADecimal32 9 2))) (V.singleton (C.primColumn C.PInt32 (VS.singleton 1)))))
  forM_ [("pa_decimal32", ADecimal32 9 2, decimalColumn (head cases)), ("pa_decimal64", ADecimal64 18 2, decimalColumn (cases !! 1))] $ \(name, ty, expected) -> do
    forM_ [".arrows", ".arrow"] $ \extension -> do
      bytes <- BS.readFile ("test/golden/" ++ name ++ extension)
      (schema, batches) <- load ((if extension == ".arrow" then S.decodeArrowFile else S.decodeArrowStream) defaultReadOptions bytes)
      check "independent PyArrow golden retains schema and values" (fieldType (V.head (arrowFields schema)) == ty && V.head (head batches) == expected)
  putStrLn "Arrow decimal width/precision/scale/nesting/endianness regressions passed"


roundtrip :: Field -> C.ColumnArray -> IO ()
roundtrip field column = do
  let schema = defaultSchema (V.singleton field)
      batch = [V.singleton column]
      encoders =
        [ S.encodeArrowStream S.defaultWriteOptions schema batch
        , BL.toStrict <$> S.encodeArrowStreamLazy S.defaultWriteOptions schema batch
        , S.encodeArrowFile S.defaultWriteOptions schema batch
        , BL.toStrict <$> S.encodeArrowFileLazy S.defaultWriteOptions schema batch
        ]
  forM_ (zip [False, False, True, True] encoders) $ \(file, encoded) -> do
    bytes <- load encoded
    (actualSchema, batches) <- load ((if file then S.decodeArrowFile else S.decodeArrowStream) defaultReadOptions bytes)
    check "schema and decimal values roundtrip without widening" (actualSchema == schema && length batches == 1 && V.head (head batches) == column)