packages feed

wireform-flatbuffers-0.1.0.0: test/Test/FlatBuffers/Builder.hs

{-# LANGUAGE OverloadedStrings #-}

{- | Properties of "FlatBuffers.Builder".

Random object trees (tables with scalar, string, vector, struct and
nested-table slots) are built with the original builder kept in
"Test.FlatBuffers.Builder.Reference" and with the current builder in
each field style: opaque writer closures (the 'B.Field'' pattern),
@'B.scalar' n (\\b -> prependX b v)@ (rewritten to data fields by the
builder's rules) and the data fields themselves ('B.scalarU8' ...).
Every build must produce the reference's bytes, and
"FlatBuffers.Reader" must read every value back from the result.
-}
module Test.FlatBuffers.Builder (tests) where

import Control.Monad (foldM, forM_, zipWithM_)
import Data.ByteString (ByteString)
import Data.ByteString qualified as BS
import Data.Int (Int16, Int32, Int64)
import Data.Text (Text)
import Data.Vector qualified as V
import Data.Word (Word8)
import FlatBuffers.Builder qualified as B
import FlatBuffers.Reader qualified as R
import Hedgehog (Gen, Property, PropertyT, annotate, evalEither, failure, forAll, property, (===))
import Hedgehog.Gen qualified as Gen
import Hedgehog.Range qualified as Range
import System.IO.Unsafe (unsafePerformIO)
import Test.FlatBuffers.Builder.Reference qualified as Ref
import Test.Syd
import Test.Syd.Hedgehog ()


tests :: Spec
tests =
  describe "FlatBuffers.Builder" $ do
    it "matches the reference builder byte for byte in every field style" prop_matchesReference
    it "reads back every value it wrote" prop_readBack
    it "keeps objects aligned and the buffer a multiple of its largest alignment" prop_aligned
    it "survives regrowth from a 16-byte initial buffer" prop_tinyCapacity
    it "deduplicates identical vtables" $ do
      bs <- build Data Nothing [Just (VTab sameShape), Just (VVecTab (replicate 5 sameShape))]
      -- The six equal-shape tables share one vtable; the root has its own.
      countVTables bs `shouldBe` 2
    it "leaves a finished buffer untouched when building continues" $ do
      b <- B.newBuilderWithCapacity 16
      s <- B.writeString b "hello"
      t <- B.writeTable b [Just (B.voff s), Just (B.scalarI64 42)]
      bs <- B.finish b t
      let copy = BS.copy bs
      _ <- B.writeString b "a longer string that forces the buffer to regrow"
      _ <- B.writeTable b [Just (B.scalarI32 7)]
      bs `shouldBe` copy
  where
    sameShape = [Just (VI32 1), Nothing, Just (VI64 2)]


-- ---------------------------------------------------------------------------
-- Object model
-- ---------------------------------------------------------------------------

-- | A table slot's content.
data Val
  = VU8 Word8
  | VI16 Int16
  | VI32 Int32
  | VI64 Int64
  | VStruct Int64 Int64
  -- ^ inline 16-byte struct field
  | VStr Text
  | VTab [Maybe Val]
  | VVecI32 [Int32]
  | VVecI64 [Int64]
  | VVecStr [Text]
  | VVecTab [[Maybe Val]]
  | VVecStruct [(Int64, Int32)]
  -- ^ vector of 16-byte structs: i64, i32, 4 bytes of padding
  deriving stock (Show)


genTable :: Int -> Gen [Maybe Val]
genTable depth = Gen.list (Range.linear 0 9) (Gen.maybe (genVal depth))


genVal :: Int -> Gen Val
genVal depth =
  Gen.frequency $
    [ (3, VU8 <$> Gen.enumBounded)
    , (3, VI16 <$> Gen.enumBounded)
    , (3, VI32 <$> Gen.enumBounded)
    , (3, VI64 <$> Gen.enumBounded)
    , (1, VStruct <$> Gen.enumBounded <*> Gen.enumBounded)
    , (2, VStr <$> genText)
    , (1, VVecI32 <$> Gen.list (Range.linear 0 12) Gen.enumBounded)
    , (1, VVecI64 <$> Gen.list (Range.linear 0 12) Gen.enumBounded)
    , (1, VVecStr <$> Gen.list (Range.linear 0 6) genText)
    , (1, VVecStruct <$> Gen.list (Range.linear 0 6) ((,) <$> Gen.enumBounded <*> Gen.enumBounded))
    ]
      <> [ (2, VTab <$> genTable (depth - 1)) | depth > 0 ]
      <> [ (1, VVecTab <$> Gen.list (Range.linear 0 4) (genTable (depth - 1))) | depth > 0 ]


genText :: Gen Text
genText = Gen.text (Range.linear 0 40) Gen.unicode


-- ---------------------------------------------------------------------------
-- Building through either implementation
-- ---------------------------------------------------------------------------

-- | The builder operations a tree needs, so one walker drives both
-- implementations.
data Ops b f = Ops
  { opNew :: IO b
  , opFinish :: b -> Int -> IO ByteString
  , opTable :: b -> [Maybe f] -> IO Int
  , opString :: b -> Text -> IO Int
  , opOffsets :: b -> [Int] -> IO Int
  , opStructs :: b -> Int -> Int -> [b -> IO ()] -> IO Int
  , opVecI32 :: b -> [Int32] -> IO Int
  , opVecI64 :: b -> [Int64] -> IO Int
  , opVOff :: Int -> f
  , opU8 :: Word8 -> f
  , opI16 :: Int16 -> f
  , opI32 :: Int32 -> f
  , opI64 :: Int64 -> f
  , opStruct :: Int64 -> Int64 -> f
  , opPrependI32 :: b -> Int32 -> IO ()
  , opPrependI64 :: b -> Int64 -> IO ()
  , opPrependZeros :: b -> Int -> IO ()
  }


refOps :: Ops Ref.Builder Ref.Field'
refOps =
  Ops
    { opNew = Ref.newBuilder
    , opFinish = Ref.finish
    , opTable = Ref.writeTable
    , opString = Ref.writeString
    , opOffsets = Ref.writeVectorOfOffsets
    , opStructs = Ref.writeVectorOfStructs
    , opVecI32 = Ref.writeVectorInt32
    , opVecI64 = Ref.writeVectorInt64
    , opVOff = Ref.voff
    , opU8 = \v -> Ref.scalar 1 (\b -> Ref.prependU8 b v)
    , opI16 = \v -> Ref.scalar 2 (\b -> Ref.prependI16 b v)
    , opI32 = \v -> Ref.scalar 4 (\b -> Ref.prependI32 b v)
    , opI64 = \v -> Ref.scalar 8 (\b -> Ref.prependI64 b v)
    , opStruct = \x y -> Ref.struct 16 8 (\b -> Ref.prependI64 b y >> Ref.prependI64 b x)
    , opPrependI32 = Ref.prependI32
    , opPrependI64 = Ref.prependI64
    , opPrependZeros = \b n -> Ref.prependBS b (BS.replicate n 0)
    }


-- | How the current builder describes scalar fields.
data Style
  = -- | the 'B.Field'' pattern: a closure 'B.writeTable' must call
    Opaque
  | -- | @'B.scalar' n (\\b -> prependX b v)@, as "Arrow.FlatBufferIPC"
    -- writes it; the builder's rewrite rules turn it into data
    Scalar
  | -- | 'B.scalarU8' ... 'B.scalarI64'
    Data
  deriving stock (Show, Enum, Bounded)


newOps :: Style -> Maybe Int -> Ops B.Builder B.Field'
newOps style capacity =
  Ops
    { opNew = maybe B.newBuilder B.newBuilderWithCapacity capacity
    , opFinish = B.finish
    , opTable = B.writeTable
    , opString = B.writeString
    , opOffsets = B.writeVectorOfOffsets
    , opStructs = B.writeVectorOfStructs
    , opVecI32 = B.writeVectorInt32
    , opVecI64 = B.writeVectorInt64
    , opVOff = B.voff
    , opU8 = case style of
        Opaque -> \v -> B.Field' 1 (\b _ -> B.prependU8 b v)
        Scalar -> \v -> B.scalar 1 (\b -> B.prependU8 b v)
        Data -> B.scalarU8
    , opI16 = case style of
        Opaque -> \v -> B.Field' 2 (\b _ -> B.prependI16 b v)
        Scalar -> \v -> B.scalar 2 (\b -> B.prependI16 b v)
        Data -> B.scalarI16
    , opI32 = case style of
        Opaque -> \v -> B.Field' 4 (\b _ -> B.prependI32 b v)
        Scalar -> \v -> B.scalar 4 (\b -> B.prependI32 b v)
        Data -> B.scalarI32
    , opI64 = case style of
        Opaque -> \v -> B.Field' 8 (\b _ -> B.prependI64 b v)
        Scalar -> \v -> B.scalar 8 (\b -> B.prependI64 b v)
        Data -> B.scalarI64
    , opStruct = \x y -> B.struct 16 8 (\b -> B.prependI64 b y >> B.prependI64 b x)
    , opPrependI32 = B.prependI32
    , opPrependI64 = B.prependI64
    , opPrependZeros = \b n -> B.prependBS b (BS.replicate n 0)
    }


-- | Lay out a tree: every slot's out-of-line content first, then the table.
buildWith :: Ops b f -> [Maybe Val] -> IO ByteString
buildWith ops root = do
  b <- opNew ops
  t <- table b root
  opFinish ops b t
  where
    table b slots = do
      fields <- mapM (traverse (slot b)) slots
      opTable ops b fields

    slot b = \case
      VU8 v -> pure (opU8 ops v)
      VI16 v -> pure (opI16 ops v)
      VI32 v -> pure (opI32 ops v)
      VI64 v -> pure (opI64 ops v)
      VStruct x y -> pure (opStruct ops x y)
      VStr s -> opVOff ops <$> opString ops b s
      VTab ts -> opVOff ops <$> table b ts
      VVecI32 xs -> opVOff ops <$> opVecI32 ops b xs
      VVecI64 xs -> opVOff ops <$> opVecI64 ops b xs
      VVecStr ss -> do
        us <- mapM (opString ops b) ss
        opVOff ops <$> opOffsets ops b us
      VVecTab tss -> do
        us <- mapM (table b) tss
        opVOff ops <$> opOffsets ops b us
      VVecStruct es ->
        opVOff ops
          <$> opStructs
            ops
            b
            16
            8
            [ \bb -> opPrependZeros ops bb 4 >> opPrependI32 ops bb y >> opPrependI64 ops bb x
            | (x, y) <- es
            ]


build :: Style -> Maybe Int -> [Maybe Val] -> IO ByteString
build style capacity = buildWith (newOps style capacity)


buildPure :: Style -> Maybe Int -> [Maybe Val] -> ByteString
buildPure style capacity root = unsafePerformIO (build style capacity root)
{-# NOINLINE buildPure #-}


buildRef :: [Maybe Val] -> ByteString
buildRef root = unsafePerformIO (buildWith refOps root)
{-# NOINLINE buildRef #-}


-- ---------------------------------------------------------------------------
-- Properties
-- ---------------------------------------------------------------------------

prop_matchesReference :: Property
prop_matchesReference = property $ do
  root <- forAll (genTable 3)
  let expected = buildRef root
  forM_ [minBound .. maxBound] $ \style -> do
    annotate (show style)
    buildPure style Nothing root === expected


prop_readBack :: Property
prop_readBack = property $ do
  root <- forAll (genTable 3)
  let bs = buildPure Data Nothing root
  rootPos <- evalEither (R.followUOffset bs 0)
  checkTable bs rootPos root


prop_aligned :: Property
prop_aligned = property $ do
  root <- forAll (genTable 3)
  let bs = buildPure Data Nothing root
  rootPos <- evalEither (R.followUOffset bs 0)
  -- Inline 8-byte scalars and structs are 8-aligned, so a buffer
  -- holding any must be a multiple of 8; it is always a multiple of 4.
  (BS.length bs `mod` (if needs8 root then 8 else 4)) === 0
  checkAlignment bs rootPos root
  where
    needs8 = any (maybe False needs8Val)
    needs8Val = \case
      VI64 _ -> True
      VStruct _ _ -> True
      VVecI64 _ -> True
      VVecStruct _ -> True
      VTab ts -> needs8 ts
      VVecTab tss -> any needs8 tss
      _ -> False


prop_tinyCapacity :: Property
prop_tinyCapacity = property $ do
  root <- forAll (genTable 3)
  forM_ [minBound .. maxBound] $ \style ->
    buildPure style (Just 16) root === buildPure style Nothing root


-- ---------------------------------------------------------------------------
-- Reading back
-- ---------------------------------------------------------------------------

checkTable :: ByteString -> R.Pos -> [Maybe Val] -> PropertyT IO ()
checkTable bs pos slots = do
  slotAt <- evalEither (R.resolveTable bs pos)
  forM_ (zip [0 ..] slots) $ \(i, mv) -> case (mv, slotAt i) of
    (Nothing, Nothing) -> pure ()
    (Nothing, Just _) -> annotate ("absent slot " <> show i <> " is present") >> failure
    (Just _, Nothing) -> annotate ("present slot " <> show i <> " is absent") >> failure
    (Just v, Just p) -> checkVal bs p v
  -- Slots past the end of the list read as absent too.
  slotAt (length slots) === Nothing


checkVal :: ByteString -> R.Pos -> Val -> PropertyT IO ()
checkVal bs p = \case
  VU8 v -> evalEither (R.peekU8 bs p) >>= (=== v)
  VI16 v -> evalEither (R.peekI16 bs p) >>= (=== v)
  VI32 v -> evalEither (R.peekI32 bs p) >>= (=== v)
  VI64 v -> evalEither (R.peekI64 bs p) >>= (=== v)
  VStruct x y -> do
    evalEither (R.peekI64 bs p) >>= (=== x)
    evalEither (R.peekI64 bs (p + 8)) >>= (=== y)
  VStr s -> do
    target <- evalEither (R.followUOffset bs p)
    evalEither (R.readString bs target) >>= (=== s)
    -- NUL terminator after the bytes.
    n <- evalEither (R.vectorLength bs target)
    evalEither (R.peekU8 bs (target + 4 + n)) >>= (=== 0)
  VTab ts -> do
    target <- evalEither (R.followUOffset bs p)
    checkTable bs target ts
  VVecI32 xs -> do
    target <- evalEither (R.followUOffset bs p)
    evalEither (R.vectorLength bs target) >>= (=== length xs)
    zipWithM_ (\i x -> evalEither (R.peekI32 bs (R.vectorElementAt target 4 i)) >>= (=== x)) [0 ..] xs
  VVecI64 xs -> do
    target <- evalEither (R.followUOffset bs p)
    evalEither (R.readVectorInt64 bs target) >>= (=== xs)
  VVecStr ss -> do
    target <- evalEither (R.followUOffset bs p)
    ps <- evalEither (R.readVectorOfOffsets bs target)
    ts <- traverse (evalEither . R.readString bs) (V.toList ps)
    ts === ss
  VVecTab tss -> do
    target <- evalEither (R.followUOffset bs p)
    ps <- evalEither (R.readVectorOfOffsets bs target)
    V.length ps === length tss
    zipWithM_ (checkTable bs) (V.toList ps) tss
  VVecStruct es -> do
    target <- evalEither (R.followUOffset bs p)
    (n, ps) <- evalEither (R.readVectorOfStructs bs target 16)
    n === length es
    zipWithM_
      ( \q (x, y) -> do
          evalEither (R.peekI64 bs q) >>= (=== x)
          evalEither (R.peekI32 bs (q + 8)) >>= (=== y)
      )
      (V.toList ps)
      es


-- | Every inline scalar sits at a multiple of its size; strings and
-- offset vectors at a multiple of 4; i64 and struct vectors have
-- 8-aligned elements.
checkAlignment :: ByteString -> R.Pos -> [Maybe Val] -> PropertyT IO ()
checkAlignment bs pos slots = do
  (pos `mod` 4) === 0
  slotAt <- evalEither (R.resolveTable bs pos)
  forM_ (zip [0 ..] slots) $ \(i, mv) -> case (mv, slotAt i) of
    (Just v, Just p) -> do
      (p `mod` inlineSize v) === 0
      case v of
        VStr _ -> out p 4 0
        VTab ts -> do
          target <- evalEither (R.followUOffset bs p)
          checkAlignment bs target ts
        VVecI32 _ -> out p 4 0
        VVecI64 _ -> out p 8 4
        VVecStr _ -> out p 4 0
        VVecTab tss -> do
          target <- evalEither (R.followUOffset bs p)
          (target `mod` 4) === 0
          ps <- evalEither (R.readVectorOfOffsets bs target)
          zipWithM_ (checkAlignment bs) (V.toList ps) tss
        -- Only the length prefix's 4-byte alignment is checked: like
        -- the original builder, 'B.writeVectorOfStructs' aligns the
        -- whole object (prefix first) to the struct alignment.
        VVecStruct _ -> out p 4 0
        _ -> pure ()
    _ -> pure ()
  where
    -- The referenced object starts @rem'@ past a multiple of @align@.
    out p align rem' = do
      target <- evalEither (R.followUOffset bs p)
      (target `mod` align) === rem'
    inlineSize = \case
      VU8 _ -> 1
      VI16 _ -> 2
      VI32 _ -> 4
      VI64 _ -> 8
      VStruct _ _ -> 16
      _ -> 4


-- | Number of distinct vtables used by the root, the table in its slot
-- 0, and the tables in the vectors of its other slots.
countVTables :: ByteString -> Int
countVTables bs = either error length $ do
  rootPos <- R.followUOffset bs 0
  slotAt <- R.resolveTable bs rootPos
  nested <- maybe (Left "slot 0 absent") (R.followUOffset bs) (slotAt 0)
  vec <- maybe (Left "slot 1 absent") (R.followUOffset bs) (slotAt 1)
  elems <- R.readVectorOfOffsets bs vec
  foldM vtableOf [] (rootPos : nested : V.toList elems)
  where
    vtableOf seen pos = do
      soff <- R.peekI32 bs pos
      let vt = pos - fromIntegral soff
      Right (if vt `elem` seen then seen else vt : seen)