wireform-parquet-0.1.0.0: test/TimestampRegression.hs
{-# LANGUAGE OverloadedStrings #-}
module TimestampRegression (tests) where
import Arrow.Column qualified as C
import Arrow.Types qualified as A
import Control.Monad (forM_, unless)
import Data.ByteString qualified as BS
import Data.ByteString.Builder qualified as B
import Data.ByteString.Lazy qualified as BL
import Data.Either (isLeft)
import Data.Int (Int32, Int64)
import Data.Vector qualified as V
import Data.Vector.Storable qualified as S
import Hedgehog qualified as H
import Hedgehog.Gen qualified as Gen
import Hedgehog.Range qualified as Range
import Parquet.Arrow qualified as Arrow
import Parquet.Nested qualified as N
import Parquet.Predicate qualified as Predicate
import Parquet.Read qualified as Read
import Parquet.Timestamp qualified as TS
import Parquet.Types qualified as P
import System.Directory (createDirectoryIfMissing)
import System.Environment (lookupEnv)
import System.FilePath ((</>))
load :: Either String a -> IO a
load = either fail pure
check :: String -> Bool -> IO ()
check label = flip unless (fail ("Timestamp regression: " ++ label))
signed :: Integer -> IO BS.ByteString
signed value = TS.encodeTimestamp96 <$> load (TS.timestamp96FromInteger value)
julian :: Int32 -> Int64 -> BS.ByteString
julian day nanos = BL.toStrict (B.toLazyByteString (B.int64LE nanos <> B.int32LE day))
file :: N.LeafType -> V.Vector (Maybe BS.ByteString) -> IO BS.ByteString
file kind values = load (N.buildNestedFile (V.singleton ("t", N.NSOptional (N.NSPrimitive kind))) (V.singleton (V.map (maybe N.NRNull (N.NRLeaf . N.LvFixedBinary)) values)))
decodeFile :: BS.ByteString -> IO (Read.ParquetFile, A.Schema, C.ColumnArray)
decodeFile bytes = do
input <- load (Read.loadParquetFile bytes)
schema <- load (Arrow.parquetFileArrowSchemaChecked input)
columns <- load (either (Left . show) Right (Arrow.parquetRowGroupToArrow schema input 0))
pure (input, schema, V.head columns)
readTarget :: A.Schema -> Read.ParquetFile -> A.Field -> Either String C.ColumnArray
readTarget schema input target = V.head <$> either (Left . show) Right (Arrow.parquetRowGroupToArrow (schema {A.arrowFields = V.singleton target}) input 0)
counts :: C.ColumnArray -> Either String (V.Vector (Maybe Int64))
counts (C.ColTimestamp validity values) = Right (V.generate (S.length values) (\i -> if C.isValidAt validity i then Just (values S.! i) else Nothing))
counts _ = Left "expected Arrow timestamp column"
tests :: IO ()
tests = do
output <- lookupEnv "WIREFORM_PARQUET_PRIMITIVE_OUTPUT"
forM_ output (createDirectoryIfMissing True)
let save name bytes = forM_ output (\path -> BS.writeFile (path </> name) bytes)
unitCases = [(P.LtMillis, A.Millisecond, "millis"), (P.LtMicros, A.Microsecond, "micros"), (P.LtNanos, A.Nanosecond, "nanos")]
bounds = V.fromList [negate (2 ^ (95 :: Int)), negate (2 ^ (63 :: Int)) - 1, negate (2 ^ (63 :: Int)), -1, 0, 2 ^ (63 :: Int) - 1, 2 ^ (63 :: Int), 2 ^ (95 :: Int) - 1]
payloads <- V.mapM signed bounds
let rows = V.map Just payloads `V.snoc` Nothing
forM_ unitCases $ \(unit, arrowUnit, label) -> forM_ [False, True] $ \adjusted -> do
bytes <- file (N.LtTimestamp96 adjusted unit) rows
(input, schema, column) <- decodeFile bytes
let field = V.head (A.arrowFields schema)
target = field {A.fieldType = A.ATimestamp arrowUnit (if adjusted then Just "UTC" else Nothing), A.fieldMetadata = V.empty}
check "modern96 annotation and units inferred losslessly" (TS.timestampEncoding field == Right (Just (TS.SignedTimestamp96 adjusted unit)))
check "all96bits and nulls preserved" (V.generate (V.length rows) (C.anyBytesAt column) == rows)
check "wide count cannot silently narrow" (isLeft (readTarget schema input target))
let wrong = TS.timestampField "t" True (TS.SignedTimestamp96 (not adjusted) unit)
check "same physical bytes cannot be relabeled with different timestamp semantics" (isLeft (readTarget schema input wrong))
nestedSchema <- load (Arrow.arrowFieldToNestedSchema field)
lowered <- load (Arrow.columnArrayToNestedRows field column)
copy <- load (N.buildNestedFile (V.singleton ("t", nestedSchema)) (V.singleton lowered))
(_, copiedSchema, copied) <- decodeFile copy
check "Arrow lowering retains96bit storage and annotation" (A.arrowFields copiedSchema == A.arrowFields schema && V.generate (V.length rows) (C.anyBytesAt copied) == rows)
save ("timestamp96-" ++ label ++ (if adjusted then "-utc" else "-local") ++ ".parquet") bytes
small <- V.mapM (signed . toInteger) (V.fromList [minBound :: Int64, -1, 0, maxBound])
narrowBytes <- file (N.LtTimestamp96 adjusted unit) (V.map Just small `V.snoc` Nothing)
(narrowInput, narrowSchema, _) <- decodeFile narrowBytes
projected <- load (readTarget narrowSchema narrowInput target >>= counts)
check "exact signed64 boundaries project" (projected == V.fromList [Just minBound, Just (-1), Just 0, Just maxBound, Nothing])
(_, pruned) <- load (Arrow.readParquetColumnWithPagePruning narrowInput 0 0 target Predicate.PIsNotNull)
check "page pruning fallback shares checked conversion" (counts pruned == Right projected)
let julianValues = V.fromList [Just (julian minBound 0), Just (julian (-1) 86399999999999), Just (julian 2440588 0), Nothing, Just (julian maxBound 86399999999999), Just (julian 2440588 minBound), Just (julian 2440588 maxBound)]
legacy <- file N.LtInt96 julianValues
(legacyInput, legacySchema, legacyColumn) <- decodeFile legacy
check "legacy schema remains distinct from signed96" (TS.timestampEncoding (V.head (A.arrowFields legacySchema)) == Right (Just TS.JulianInt96))
check "full legacy day domain preserved" (V.generate (V.length julianValues) (C.anyBytesAt legacyColumn) == julianValues)
let nanoTarget = A.defaultField "t" True (A.ATimestamp A.Nanosecond Nothing) V.empty
check "legacy overflow cannot wrap" (isLeft (readTarget legacySchema legacyInput nanoTarget))
save "int96-full.parquet" legacy
forM_ [(A.Second, 1000000000), (A.Millisecond, 1000000), (A.Microsecond, 1000), (A.Nanosecond, 1)] $ \(unit, factor) -> do
let source = TS.timestampField "t" True TS.JulianInt96
target = A.defaultField "t" True (A.ATimestamp unit Nothing) V.empty
values = V.fromList [Just (julian 2440587 (86400000000000 - factor)), Nothing, Just (julian 2440588 factor)]
bytes <- file N.LtInt96 values
(input, schema, _) <- decodeFile bytes
converted <- load (readTarget schema input target >>= counts)
check "legacy exact negative and positive units" (converted == V.fromList [Just (-1), Nothing, Just 1])
poison <- load (C.fromMaybeFixedSizeBinary 12 (V.fromList [Just (julian 2440588 factor), Nothing]))
check "scalar column projection agrees" ((TS.projectTimestampColumn source target poison >>= counts) == Right (V.fromList [Just 1, Nothing]))
if factor == 1
then pure ()
else do
lossy <- load (C.fromMaybeFixedSizeBinary 12 (V.singleton (Just (julian 2440588 1))))
check "legacy projection rejects sub-unit loss" (isLeft (TS.projectTimestampColumn source target lossy))
forM_ [minBound, -1, 86400000000000, maxBound] $ \nanos -> do
let bytes = julian 2440588 nanos
check "legacy signed/non-normalized nanoseconds preserved" ((TS.timestamp96ToInteger <$> TS.decodeJulianInt96 bytes) == Right (toInteger nanos))
check "legacy non-normalized values remain writable" (not (isLeft (N.shred (N.NSPrimitive N.LtInt96) (V.singleton (N.NRLeaf (N.LvFixedBinary bytes))))))
check "scalar signed96 bounds checked" (isLeft (TS.timestamp96FromInteger (negate (2 ^ (95 :: Int)) - 1)) && isLeft (TS.timestamp96FromInteger (2 ^ (95 :: Int))))
check "scalar twelve-byte bounds checked" (all (isLeft . TS.decodeTimestamp96 . (`BS.replicate` 0)) [0, 11, 13])
let base = TS.timestampField "t" True (TS.SignedTimestamp96 False P.LtNanos)
malformed = [base {A.fieldType = A.ABinary}, base {A.fieldMetadata = A.fieldMetadata base `V.snoc` ("ARROW:extension:name", "other")}, base {A.fieldMetadata = V.fromList [("ARROW:extension:name", "wireform.parquet.timestamp96"), ("ARROW:extension:metadata", "bad")]}]
forM_ malformed (\field -> check "malformed extension rejected even for empty schema" (isLeft (Arrow.arrowFieldToNestedSchema field)))
-- Reachability matters: the required timestamp child below a null struct
-- can contain arbitrary bits; it must not trigger semantic validation.
let child = TS.timestampField "t" False TS.JulianInt96
parent = A.defaultField "s" True A.AStruct (V.singleton child)
poisoned <- load (C.mkFixedSizeBinary 12 2 Nothing (julian 2440588 1 <> BS.replicate 12 255))
structure <- load (C.mkStruct 2 (C.validityGenerate 2 (== 0)) (V.singleton ("t", poisoned)))
parentSchema <- load (Arrow.arrowFieldToNestedSchema parent)
parentRows <- load (Arrow.columnArrayToNestedRows parent structure)
nested <- load (N.buildNestedFile (V.singleton ("s", parentSchema)) (V.singleton parentRows))
(nestedInput, nestedSchema, _) <- decodeFile nested
let nestedTarget = parent {A.fieldChildren = V.singleton (A.defaultField "t" False (A.ATimestamp A.Nanosecond Nothing) V.empty)}
nestedColumns <- load (either (Left . show) Right (Arrow.parquetRowGroupToArrow (nestedSchema {A.arrowFields = V.singleton nestedTarget}) nestedInput 0))
check "nested conversion retains parent null" (C.nullCount (V.head nestedColumns) == 1)
ok <- H.check $ H.withTests 500 $ H.property $ do
high <- H.forAll (Gen.choice [Gen.int32 Range.constantBounded, Gen.element [-1, 0]])
low <- H.forAll (Gen.word64 Range.constantBounded)
let value = TS.Timestamp96 high low
encoded <- H.evalEither (TS.timestamp96FromInteger (TS.timestamp96ToInteger value))
decoded <- H.evalEither (TS.decodeTimestamp96 (TS.encodeTimestamp96 encoded))
decoded H.=== value
let integer = toInteger high * 18446744073709551616 + toInteger low
unsignedBytes = BS.foldr (\byte rest -> rest * 256 + toInteger byte) 0 (TS.encodeTimestamp96 encoded)
unsignedBytes H.=== integer `mod` (2 ^ (96 :: Int))
column <- H.evalEither (C.mkFixedSizeBinary 12 1 Nothing (TS.encodeTimestamp96 value))
let source = TS.timestampField "t" False (TS.SignedTimestamp96 False P.LtNanos)
target = A.defaultField "t" False (A.ATimestamp A.Nanosecond Nothing) V.empty
projected = TS.projectTimestampColumn source target column >>= counts
if integer >= toInteger (minBound :: Int64) && integer <= toInteger (maxBound :: Int64)
then do
actual <- H.evalEither projected
actual H.=== V.singleton (Just (fromInteger integer))
else H.assert (isLeft projected)
check "generated96bit codec and independent checked-projection oracle" ok