packages feed

wireform-parquet-0.1.0.0: test/VariantParquetRegression.hs

{-# LANGUAGE OverloadedStrings #-}

module VariantParquetRegression (tests) where

import Arrow.Column qualified as C
import Arrow.Types qualified as A
import Control.Exception (evaluate)
import Control.Monad (forM_, unless)
import Data.ByteString qualified as BS
import Data.Either (isLeft, isRight)
import Data.Int (Int32)
import Data.Map.Strict qualified as M
import Data.Text qualified as T
import Data.Vector qualified as V
import GHC.Conc (getAllocationCounter)
import Parquet.Arrow qualified as Arrow
import Parquet.Nested qualified as N
import Parquet.Read qualified as Read
import Parquet.Types qualified as P
import Parquet.Variant qualified as Variant
import Parquet.Variant.Parquet qualified as VP
import Parquet.Variant.Schema qualified as VariantSchema
import VariantRegression qualified as Cases


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


tests :: IO ()
tests = do
  let check name condition = unless condition (fail ("Variant Parquet regression: " ++ name))
      values = V.cons Nothing (Just <$> Cases.values)
      canonical Nothing = Right Nothing
      canonical (Just value) = Just <$> Variant.encodeVariant value
  bytes <- load (VP.buildVariantParquetFile "v" values)
  file <- load (Read.loadParquetFile bytes)
  output <- load (VP.readVariantColumn file 0 "v")
  check "all primitive/container payloads and IEEE bits survive the file" (traverse canonical output == traverse canonical values)
  let footer = Read.pfFooter file
      schema = P.fmSchema footer
      withoutAnnotation = file {Read.pfFooter = footer {P.fmSchema = V.imap (\i element -> if i == 1 then element {P.seLogicalType = Nothing} else element) schema}}
  check "native writer emits VARIANT(1)" (P.seLogicalType (schema V.! 1) == Just (P.LTVariant 1))
  check "semantic file API requires annotation" (isLeft (VP.readVariantColumn withoutAnnotation 0 "v"))
  check "missing column fails" (isLeft (VP.readVariantColumn file 0 "absent"))
  -- Reorder footer children and column descriptors without touching their
  -- data bytes. The semantic reader must use names instead of positions.
  let reorderedSchema = V.fromList [schema V.! 0, schema V.! 1, schema V.! 3, schema V.! 2]
      reorderedGroups = V.map (\group -> group {P.rgColumns = V.reverse (P.rgColumns group)}) (P.fmRowGroups footer)
      reordered = file {Read.pfFooter = footer {P.fmSchema = reorderedSchema, P.fmRowGroups = reorderedGroups}}
  reorderedValues <- load (VP.readVariantColumn reordered 0 "v")
  check "metadata/value fields are accessed by name" (traverse canonical reorderedValues == traverse canonical values)
  forM_ [0, 2] $ \version -> do
    let changed = file {Read.pfFooter = footer {P.fmSchema = schema V.// [(1, (schema V.! 1) {P.seLogicalType = Just (P.LTVariant version)})]}}
    check "unknown annotation version is not interpreted as v1" (isLeft (VP.readVariantColumn changed 0 "v"))
  forM_ Cases.malformed $ \(name, metadata, value) ->
    check ("writer rejects malformed " ++ name) (isLeft (N.buildNestedFile (V.singleton ("v", N.NSVariant)) (V.singleton (V.singleton (N.NRVariantBytes metadata value)))))
  let invalidVariant = N.NRVariantBytes (BS.pack [1, 0, 0]) (BS.singleton 84)
      integer = N.NRLeaf (N.LvInt32 1)
      nestedShapes =
        [ (N.NSStruct (V.fromList [("ordinary", N.NSPrimitive N.LtInt32), ("values", N.NSList (N.NSOptional N.NSVariant))]), N.NRStruct (V.fromList [integer, N.NRList (V.fromList [N.NRNull, invalidVariant])]))
        , (N.NSMap N.NSVariant (N.NSPrimitive N.LtInt32), N.NRMapEntries (V.singleton (invalidVariant, integer)))
        , (N.NSMap (N.NSPrimitive N.LtInt32) (N.NSOptional N.NSVariant), N.NRMapEntries (V.singleton (integer, invalidVariant)))
        ]
  forM_ nestedShapes $ \(shape, row) ->
    check "deferred semantic validation visits nested structs/lists/map keys and values" (isLeft (N.shred shape (V.singleton row)))
  -- Ordinary unannotated binary data must remain byte preserving even when
  -- a caller chooses the names metadata/value and the bytes aren't Variants.
  raw <- load (N.buildNestedFile (V.singleton ("v", N.NSStruct (V.fromList [("metadata", N.NSPrimitive N.LtBinary), ("value", N.NSPrimitive N.LtBinary)]))) (V.singleton (V.singleton (N.NRStruct (V.fromList [N.NRLeaf (N.LvBinary "invalid"), N.NRLeaf (N.LvBinary "bytes")])))))
  rawFile <- load (Read.loadParquetFile raw)
  rawSchema <- load (Arrow.parquetFileArrowSchemaChecked rawFile)
  check "ordinary binary reads preserve arbitrary bytes" (isRight (Arrow.parquetRowGroupToArrow rawSchema rawFile 0))
  let sample = V.fromList [Just (Variant.VInt8 4), Nothing, Just Variant.VNull, Just (Variant.VObject (M.singleton "nested" (Variant.VBool True)))]
  encoded <- V.mapM (maybe (pure Nothing) (fmap Just . load . Variant.encodeVariant)) sample
  let metadata = V.imap (\i value -> if i == 1 then Just "garbage" else Variant.variantMetadata <$> value) encoded
      payload = V.imap (\i value -> if i == 1 then Just "garbage" else Variant.variantValue <$> value) encoded
      validity = C.validityFromBools (V.fromList [True, False, True, True])
  forM_ [(C.fromMaybeByteStrings metadata, C.fromMaybeByteStrings payload), (C.fromMaybeLargeByteStrings metadata, C.fromMaybeLargeByteStrings payload), (C.fromMaybeBinaryView metadata, C.fromMaybeBinaryView payload)] $ \(m, v) -> do
    column <- load (C.mkStruct 4 validity (V.fromList [("value", v), ("metadata", m)]))
    reader <- load (VP.compileVariantReader column)
    actual <- load (V.generateM 4 (VP.variantAt reader))
    check "parent null masks arbitrary children in all binary storage profiles" (traverse canonical actual == traverse canonical sample)
    check "Variant null and absent slot stay distinct" (actual V.! 1 == Nothing && actual V.! 2 == Just Variant.VNull)
    check "row bounds rejected" (isLeft (VP.variantAt reader (-1)) && isLeft (VP.variantAt reader 4))
  missingMetadata <- load (C.mkStruct 1 Nothing (V.fromList [("metadata", C.fromMaybeByteStrings (V.singleton Nothing)), ("value", C.fromMaybeByteStrings (V.singleton (Just (BS.singleton 0))))]))
  reader <- load (VP.compileVariantReader missingMetadata)
  check "present parent requires metadata" (isLeft (VP.variantAt reader 0))
  let deep = foldr (\_ value -> Variant.VArray (V.singleton value)) Variant.VNull [1 .. 501 :: Int]
      raised = Variant.VariantLimits 501 502 0
  deepBytes <- load (VP.buildVariantParquetFileWithLimits raised "v" (V.singleton (Just deep)))
  deepFile <- load (Read.loadParquetFile deepBytes)
  check "default depth limit applies on semantic file reads" (isLeft (VP.readVariantColumn deepFile 0 "v"))
  deepOutput <- load (VP.readVariantColumnWithLimits raised deepFile 0 "v")
  check "explicit resource override works through both file APIs" (deepOutput == V.singleton (Just deep))
  -- The dictionary is valid and large, so a test using arbitrary invalid
  -- bytes would miss eager validation before the cumulative page bound.
  bigMetadata <- load (Variant.buildVariantMetadata (V.singleton (T.replicate (1024 * 1024) "a")))
  let dictionaryBytes = Variant.metadataBytes bigMetadata
      count = fromIntegral (maxBound :: Int32) `quot` (BS.length dictionaryBytes + 4) + 1
      bigRows = V.replicate count (N.NRVariantBytes dictionaryBytes (BS.singleton 0))
  _ <- evaluate (BS.length dictionaryBytes)
  _ <- evaluate (V.length bigRows)
  forM_ [N.shred N.NSVariant bigRows >> pure (), N.buildNestedFile (V.singleton ("v", N.NSVariant)) (V.singleton bigRows) >> pure ()] $ \operation -> do
    before <- getAllocationCounter
    rejected <- evaluate (isLeft operation)
    after <- getAllocationCounter
    check "cumulative size bound precedes valid dictionary parsing" rejected
    check ("rejected valid Variant page stays below allocation bound: " ++ show (before - after)) (before - after < 8 * 1024 * 1024)
  nativeTests
  putStrLn "Variant Parquet annotation/semantic/null/limits regressions passed"


-- Build real encoded physical leaves with the normal nested writer, then
-- annotate the parsed group. The external suite also tests actual foreign
-- footers; these cases isolate native schema/column/projection invariants.
nativeTests :: IO ()
nativeTests = do
  let check name good = unless good (fail ("native Variant regression: " ++ name))
      annotate file =
        let footer = Read.pfFooter file
            schema = P.fmSchema footer
        in file {Read.pfFooter = footer {P.fmSchema = schema V.// [(1, (schema V.! 1) {P.seLogicalType = Just (P.LTVariant 1)})]}}
      metadata = N.NRLeaf (N.LvBinary (BS.pack [1, 0, 0]))
      value = N.NRLeaf (N.LvBinary (BS.singleton 0))
      make shape rows = annotate <$> (load . Read.loadParquetFile =<< load (N.buildNestedFile (V.singleton ("v", shape)) (V.singleton rows)))
      primitive leaf = N.NSStruct (V.fromList [("metadata", N.NSPrimitive N.LtBinary), ("value", N.NSOptional (N.NSPrimitive N.LtBinary)), ("typed_value", N.NSOptional (N.NSPrimitive leaf))])
      row typed = N.NRStruct (V.fromList [metadata, N.NRNull, N.NRLeaf typed])
      cases =
        [ (N.LtDecimal 9 2, N.LvInt32 12345, A.ADecimal32 9 2, Variant.VDecimal4 2 12345)
        , (N.LtDecimal 10 3, N.LvInt64 (-12345), A.ADecimal64 10 3, Variant.VDecimal8 3 (-12345))
        , (N.LtDecimal 19 4, N.LvFixedBinary (BS.replicate 14 0 <> BS.pack [48, 57]), A.ADecimal 19 4, Variant.VDecimal16 4 12345)
        , (N.LtUuid, N.LvFixedBinary (BS.pack [0 .. 15]), A.AFixedSizeBinary 16, Variant.VUuid (BS.pack [0 .. 15]))
        , (N.LtTimestamp True P.LtNanos, N.LvInt64 0, A.ATimestamp A.Nanosecond (Just "UTC"), Variant.VTimestampNanos 0)
        ]
  forM_ cases $ \(leaf, input, typ, expected) -> do
    file <- make (primitive leaf) (V.fromList [row input, N.NRStruct (V.fromList [metadata, value, N.NRNull])])
    schema <- load (Arrow.parquetFileArrowSchemaChecked file)
    let field = V.head (A.arrowFields schema)
        typed = A.fieldChildren field V.! 2
    check "native field carries canonical extension" (VariantSchema.isVariantField field)
    check "exact typed decimal/timestamp/UUID field type" (A.fieldType typed == typ)
    load (VariantSchema.validateVariantField field)
    actual <- load (VP.readVariantColumn file 0 "v")
    check "native typed value preserves Variant identity and null" (actual == V.fromList [Just expected, Just Variant.VNull])
  intFile <- make (primitive N.LtInt32) (V.singleton (row (N.LvInt32 7)))
  schema <- load (Arrow.parquetFileArrowSchemaChecked intFile)
  let field = V.head (A.arrowFields schema)
      fields = A.fieldChildren field
      target changed = schema {A.arrowFields = V.singleton changed}
      widened = field {A.fieldChildren = fields V.// [(2, (fields V.! 2) {A.fieldType = A.AInt 64 True})]}
      dropped = field {A.fieldChildren = V.filter ((/= "value") . A.fieldName) fields}
  check "retained Variant extension forbids integer widening" (isLeft (Arrow.parquetRowGroupToArrow (target widened) intFile 0))
  check "retained Variant extension forbids dropping fallback storage" (isLeft (Arrow.parquetRowGroupToArrow (target dropped) intFile 0))
  check "explicit raw projection may widen and omit fields" (isRight (Arrow.parquetRowGroupToArrow (target widened {A.fieldMetadata = V.empty, A.fieldChildren = V.singleton ((fields V.! 2) {A.fieldType = A.AInt 64 True})}) intFile 0))
  let reordered = field {A.fieldChildren = V.reverse fields}
  check "retained Variant permits lossless field reorder" (isRight (Arrow.parquetRowGroupToArrow (target reordered) intFile 0))
  forM_ [N.LtDecimal 9 2, N.LtDecimal 18 2] $ \leaf -> do
    file <- make (primitive leaf) (V.singleton (row (if leaf == N.LtDecimal 9 2 then N.LvInt32 1 else N.LvInt64 1)))
    let footer = Read.pfFooter file
        original = P.fmSchema footer
        leafIndex = V.length original - 1
        native = original V.! leafIndex
        invalid = native {P.seType = if P.seType native == Just P.PTInt32 then Just P.PTInt64 else Just P.PTInt32}
        malformed = file {Read.pfFooter = footer {P.fmSchema = original V.// [(leafIndex, invalid)]}}
    check "wrong native decimal precision carrier rejected before decode" (isLeft (Arrow.parquetFileArrowSchemaChecked malformed))
  forM_ [N.LtInteger 32 False, N.LtTimestamp False P.LtMillis, N.LtTime True P.LtMicros, N.LtJson, N.LtFixedBinary 16] $ \leaf -> do
    -- No rows are needed: schema validity must not depend on row-group data.
    file <- make (primitive leaf) V.empty
    check "noncanonical typed primitive rejected on empty data" (isLeft (Arrow.parquetFileArrowSchemaChecked file))
  -- A LIST wrapper called array normally triggers the legacy two-level
  -- heuristic. Known Variant lists must always retain the required element.
  let element = N.NSStruct (V.singleton ("typed_value", N.NSOptional (N.NSPrimitive N.LtInt32)))
      arrayShape = N.NSStruct (V.fromList [("metadata", N.NSPrimitive N.LtBinary), ("typed_value", N.NSOptional (N.NSList element))])
      arrayRow = N.NRStruct (V.fromList [metadata, N.NRList (V.fromList [N.NRStruct (V.singleton (N.NRLeaf (N.LvInt32 3))), N.NRStruct (V.singleton N.NRNull)])])
  file <- make arrayShape (V.singleton arrayRow)
  let footer = Read.pfFooter file
      original = P.fmSchema footer
      renamed = V.map (\se -> if P.seName se == "list" then se {P.seName = "array"} else se) original
      renamePath name = if name == "list" then "array" else name
      groups = V.map (\group -> group {P.rgColumns = V.map (\column -> column {P.ccMetadata = fmap (\meta -> meta {P.cmPathInSchema = V.map renamePath (P.cmPathInSchema meta)}) (P.ccMetadata column)}) (P.rgColumns group)}) (P.fmRowGroups footer)
      changed = file {Read.pfFooter = footer {P.fmSchema = renamed, P.fmRowGroups = groups}}
  actual <- load (VP.readVariantColumn changed 0 "v")
  check "exact Variant three-level list bypasses legacy-name heuristic" (actual == V.singleton (Just (Variant.VArray (V.fromList [Variant.VInt32 3, Variant.VNull]))))

  let mutate file change =
        let footer = Read.pfFooter file
        in file {Read.pfFooter = footer {P.fmSchema = change (P.fmSchema footer)}}
      changeLast f elements = elements V.// [(V.length elements - 1, f (V.last elements))]
  forM_ [(8, True, 128), (16, True, 32768), (8, False, 256), (16, False, 65536)] $ \(width, signed, badValue) -> do
    wide <- make (primitive N.LtInt32) (V.singleton (row (N.LvInt32 badValue)))
    let invalid = mutate wide (changeLast (\se -> se {P.seLogicalType = Just (P.LTInteger width signed)}))
    check "out-of-domain narrow integer is rejected instead of wrapping" (isLeft (VP.readVariantColumn invalid 0 "v"))
  decimal <- make (primitive (N.LtDecimal 9 0)) (V.singleton (row (N.LvInt32 999999999)))
  let outOfRange = mutate decimal (changeLast (\se -> se {P.seLogicalType = Just (P.LTDecimal 8 0)}))
  check "narrow decimal buffer enforces declared precision" (isLeft (VP.readVariantColumn outOfRange 0 "v"))
  forM_ [(2, Just P.Optional), (3, Just P.Required), (4, Just P.Required)] $ \(index, repetition) -> do
    let malformed = mutate intFile (\elements -> elements V.// [(index, (elements V.! index) {P.seRepetition = repetition})])
    check "Variant metadata/value/typed repetition is validated before decode" (isLeft (Arrow.parquetFileArrowSchemaChecked malformed))
  forM_ [0, 1] $ \count -> do
    unshredded <- make (N.NSStruct (V.fromList [("metadata", N.NSPrimitive N.LtBinary), ("value", N.NSPrimitive N.LtBinary)])) (V.replicate count (N.NRStruct (V.fromList [metadata, value])))
    let zeroTyped =
          mutate
            unshredded
            ( \elements ->
                let parent = (elements V.! 1) {P.seNumChildren = Just 3}
                    fallback = (elements V.! 3) {P.seRepetition = Just P.Optional}
                    typed = P.SchemaElement "typed_value" (Just P.Optional) Nothing (Just 0) Nothing Nothing Nothing Nothing
                in (elements V.// [(1, parent), (3, fallback)]) `V.snoc` typed
            )
    inferred <- load (Arrow.parquetFileArrowSchemaChecked zeroTyped)
    if count == 0
      then check "zero-row native empty typed object has exact canonical schema" (isRight (Arrow.parquetRowGroupToArrow inferred zeroTyped 0))
      else check "optional zero-leaf object presence cannot be invented" (isLeft (Arrow.parquetRowGroupToArrow inferred zeroTyped 0))