packages feed

wireform-parquet-0.1.0.0: src/Parquet/PageIndex.hs

{-# LANGUAGE BangPatterns #-}
{-# LANGUAGE PatternSynonyms #-}

{- | Apache Parquet page index: 'OffsetIndex' + 'ColumnIndex' (Thrift).

The page index tier (added in parquet-format 2.5) lets readers skip
entire pages without scanning column chunks. It is stored separately
from the row-group metadata, near the file footer:

* 'OffsetIndex' — per-page @(offset, compressed_page_size, first_row_index)@
  plus optional @unencoded_byte_array_data_bytes@ (size_statistics).
* 'ColumnIndex' — per-page @null_pages@, @min_values@, @max_values@,
  plus a 'BoundaryOrder' and optional null/level histograms.

'ColumnChunk' carries 'ccOffsetIndexOffset' / @offset_index_length@ and
'ccColumnIndexOffset' / @column_index_length@; this module reads/writes
the structures themselves with the Thrift Compact Protocol used by
"Parquet.Footer".
-}
module Parquet.PageIndex (
  -- * OffsetIndex
  encodeOffsetIndex,
  decodeOffsetIndex,
  readOffsetIndex,

  -- * ColumnIndex
  encodeColumnIndex,
  decodeColumnIndex,
  readColumnIndex,

  -- * Slicing
  columnChunkOffsetIndexSlice,
  columnChunkColumnIndexSlice,
) where

import Data.ByteString (ByteString)
import Data.ByteString qualified as BS
import Data.Int (Int16, Int32, Int64)
import Data.Text.Encoding qualified
import Data.Vector qualified as V
import Parquet.Encryption qualified as Encryption
import Parquet.Read (ParquetFile (..), decryptFileColumnModule, rawColumnChunkSlice)
import Parquet.Thrift.Schema
import Parquet.Types (
  ColumnChunk (..),
  ColumnIndex (..),
  ColumnMetadata (..),
  FileMetadata (..),
  OffsetIndex (..),
  PageLocation (..),
  RowGroup (..),
  boundaryOrderToInt,
  intToBoundaryOrder,
 )
import Thrift.Decode (decodeCompactBinaryStrings)
import Thrift.Encode (encodeCompact)
import Thrift.Value qualified as TV


-- ============================================================
-- OffsetIndex
-- ============================================================

-- | Serialize an 'OffsetIndex' in Thrift Compact Protocol.
encodeOffsetIndex :: OffsetIndex -> ByteString
encodeOffsetIndex = encodeCompact . offsetIndexToThrift


-- | Parse an 'OffsetIndex' from a Thrift Compact byte slice.
decodeOffsetIndex :: ByteString -> Either String OffsetIndex
decodeOffsetIndex bs = decodeCompactBinaryStrings bs >>= thriftToOffsetIndex


{- | Look up the 'OffsetIndex' for the column chunk at @(rowGroupIdx, columnIdx)@
if 'ccOffsetIndexOffset' / 'ccOffsetIndexLength' are populated and within
file bounds.
-}
readOffsetIndex
  :: ParquetFile
  -> Int
  -> Int
  -> Either String (Maybe OffsetIndex)
readOffsetIndex pf rg col = do
  msl <- columnChunkOffsetIndexSlice pf rg col
  case msl of
    Nothing -> Right Nothing
    Just bs -> do
      index <- decodeOffsetIndex bs
      validateColumnOffsets pf rg col index
      Right (Just index)


{- | Slice a 'ParquetFile' to the bytes covering a column chunk's serialized
'OffsetIndex'. Returns 'Nothing' when the column chunk did not record one.
-}
columnChunkOffsetIndexSlice
  :: ParquetFile
  -> Int
  -> Int
  -> Either String (Maybe ByteString)
columnChunkOffsetIndexSlice pf rgIdx colIdx = do
  cc <- selectColumnChunk pf rgIdx colIdx
  case (ccOffsetIndexOffset cc, ccOffsetIndexLength cc) of
    (Just off, Just len) -> do
      bytes <- sliceFile pf off len "offset index"
      Just <$> decryptFileColumnModule pf rgIdx colIdx Encryption.ModuleOffsetIndex bytes
    _ -> Right Nothing


-- ============================================================
-- ColumnIndex
-- ============================================================

-- | Serialize a 'ColumnIndex' in Thrift Compact Protocol.
encodeColumnIndex :: ColumnIndex -> ByteString
encodeColumnIndex = encodeCompact . columnIndexToThrift


-- | Parse a 'ColumnIndex' without interpreting opaque min/max bytes as text.
decodeColumnIndex :: ByteString -> Either String ColumnIndex
decodeColumnIndex bs = decodeCompactBinaryStrings bs >>= thriftToColumnIndex


{- | Look up the 'ColumnIndex' for the column chunk at @(rowGroupIdx,
columnIdx)@.
-}
readColumnIndex
  :: ParquetFile
  -> Int
  -> Int
  -> Either String (Maybe ColumnIndex)
readColumnIndex pf rg col = do
  msl <- columnChunkColumnIndexSlice pf rg col
  case msl of
    Nothing -> Right Nothing
    Just bs -> Just <$> decodeColumnIndex bs


{- | Slice a 'ParquetFile' to the bytes covering a column chunk's serialized
'ColumnIndex'. Returns 'Nothing' when the column chunk did not record one.
-}
columnChunkColumnIndexSlice
  :: ParquetFile
  -> Int
  -> Int
  -> Either String (Maybe ByteString)
columnChunkColumnIndexSlice pf rgIdx colIdx = do
  cc <- selectColumnChunk pf rgIdx colIdx
  case (ccColumnIndexOffset cc, ccColumnIndexLength cc) of
    (Just off, Just len) -> do
      bytes <- sliceFile pf off len "column index"
      Just <$> decryptFileColumnModule pf rgIdx colIdx Encryption.ModuleColumnIndex bytes
    _ -> Right Nothing


-- ============================================================
-- Internal: Thrift conversions
-- ============================================================

offsetIndexToThrift :: OffsetIndex -> TV.Value
offsetIndexToThrift oi =
  TV.Struct $
    V.fromList $
      concat
        [ [OffsetIndex_PageLocations (V.map pageLocationToThrift (oiPageLocations oi))]
        , optField
            (oiUnencodedByteArrayDataBytes oi)
            (OffsetIndex_UnencodedByteArrayDataBytes . V.map TV.I64)
        ]


pageLocationToThrift :: PageLocation -> TV.Value
pageLocationToThrift pl =
  TV.Struct $
    V.fromList
      [ PageLocation_Offset (plOffset pl)
      , PageLocation_CompressedPageSize (plCompressedPageSize pl)
      , PageLocation_FirstRowIndex (plFirstRowIndex pl)
      ]


thriftToOffsetIndex :: TV.Value -> Either String OffsetIndex
thriftToOffsetIndex (TV.Struct fields) = do
  let fm = V.toList fields
  pages <- case findField
    fm
    ( \case
        OffsetIndex_PageLocations xs -> Just xs
        _ -> Nothing
    ) of
    Just vs -> V.mapM thriftToPageLocation vs
    Nothing -> Left "Parquet.PageIndex: missing page_locations"
  unencV <- case findField
    fm
    ( \case
        OffsetIndex_UnencodedByteArrayDataBytes xs -> Just xs
        _ -> Nothing
    ) of
    Just vs -> Just <$> V.mapM expectI64 vs
    Nothing -> Right Nothing
  Right
    OffsetIndex
      { oiPageLocations = pages
      , oiUnencodedByteArrayDataBytes = unencV
      }
thriftToOffsetIndex _ = Left "Parquet.PageIndex: expected struct for OffsetIndex"


thriftToPageLocation :: TV.Value -> Either String PageLocation
thriftToPageLocation (TV.Struct fields) = do
  let fm = V.toList fields
  off <- requireField fm "page_location.offset" $ \case
    PageLocation_Offset v -> Just v
    _ -> Nothing
  csz <- requireField fm "page_location.compressed_page_size" $ \case
    PageLocation_CompressedPageSize v -> Just v
    _ -> Nothing
  fri <- requireField fm "page_location.first_row_index" $ \case
    PageLocation_FirstRowIndex v -> Just v
    _ -> Nothing
  Right
    PageLocation
      { plOffset = off
      , plCompressedPageSize = csz
      , plFirstRowIndex = fri
      }
thriftToPageLocation _ =
  Left "Parquet.PageIndex: expected struct for PageLocation"


columnIndexToThrift :: ColumnIndex -> TV.Value
columnIndexToThrift ci =
  TV.Struct $
    V.fromList $
      concat
        [
          [ ColumnIndex_NullPages (V.map TV.Bool (ciNullPages ci))
          , ColumnIndex_MinValues (V.map TV.Binary (ciMinValues ci))
          , ColumnIndex_MaxValues (V.map TV.Binary (ciMaxValues ci))
          , ColumnIndex_BoundaryOrder (boundaryOrderToInt (ciBoundaryOrder ci))
          ]
        , optField
            (ciNullCounts ci)
            (ColumnIndex_NullCounts . V.map TV.I64)
        , optField
            (ciNanCounts ci)
            (ColumnIndex_NanCounts . V.map TV.I64)
        , optField
            (ciRepetitionLevelHistograms ci)
            (ColumnIndex_RepetitionLevelHistograms . V.map TV.I64)
        , optField
            (ciDefinitionLevelHistograms ci)
            (ColumnIndex_DefinitionLevelHistograms . V.map TV.I64)
        ]


thriftToColumnIndex :: TV.Value -> Either String ColumnIndex
thriftToColumnIndex (TV.Struct fields) = do
  let fm = V.toList fields
  nullPages <- case findField
    fm
    ( \case
        ColumnIndex_NullPages xs -> Just xs
        _ -> Nothing
    ) of
    Just vs -> V.mapM expectBool vs
    Nothing -> Left "Parquet.PageIndex: missing null_pages"
  mins <- case findField
    fm
    ( \case
        ColumnIndex_MinValues xs -> Just xs
        _ -> Nothing
    ) of
    Just vs -> V.mapM expectBinary vs
    Nothing -> Left "Parquet.PageIndex: missing min_values"
  maxs <- case findField
    fm
    ( \case
        ColumnIndex_MaxValues xs -> Just xs
        _ -> Nothing
    ) of
    Just vs -> V.mapM expectBinary vs
    Nothing -> Left "Parquet.PageIndex: missing max_values"
  boInt <- requireField fm "boundary_order" $ \case
    ColumnIndex_BoundaryOrder v -> Just v
    _ -> Nothing
  bo <-
    maybe
      (Left $ "Parquet.PageIndex: invalid boundary_order " ++ show boInt)
      Right
      (intToBoundaryOrder boInt)
  ncv <- optListI64 fm $ \case
    ColumnIndex_NullCounts xs -> Just xs
    _ -> Nothing
  nanv <- optListI64 fm $ \case
    ColumnIndex_NanCounts xs -> Just xs
    _ -> Nothing
  repv <- optListI64 fm $ \case
    ColumnIndex_RepetitionLevelHistograms xs -> Just xs
    _ -> Nothing
  defv <- optListI64 fm $ \case
    ColumnIndex_DefinitionLevelHistograms xs -> Just xs
    _ -> Nothing
  let count = V.length nullPages
  if V.length mins /= count
    || V.length maxs /= count
    || maybe False ((/= count) . V.length) ncv
    || maybe False ((/= count) . V.length) nanv
    then Left "Parquet.PageIndex: per-page vector lengths differ"
    else Right ()
  if any (maybe False (V.any (< 0))) [ncv, nanv, repv, defv]
    then Left "Parquet.PageIndex: negative count or level frequency"
    else Right ()
  Right
    ColumnIndex
      { ciNullPages = nullPages
      , ciMinValues = mins
      , ciMaxValues = maxs
      , ciBoundaryOrder = bo
      , ciNullCounts = ncv
      , ciNanCounts = nanv
      , ciRepetitionLevelHistograms = repv
      , ciDefinitionLevelHistograms = defv
      }
thriftToColumnIndex _ =
  Left "Parquet.PageIndex: expected struct for ColumnIndex"


optListI64
  :: [(Int16, TV.Value)]
  -> ((Int16, TV.Value) -> Maybe (V.Vector TV.Value))
  -> Either String (Maybe (V.Vector Int64))
optListI64 fm probe = case findField fm probe of
  Just vs -> Just <$> V.mapM expectI64 vs
  Nothing -> Right Nothing


-- ============================================================
-- Helpers
-- ============================================================

selectColumnChunk
  :: ParquetFile -> Int -> Int -> Either String ColumnChunk
selectColumnChunk pf rgIdx colIdx = do
  let rgs = fmRowGroups (pfFooter pf)
  if rgIdx < 0 || rgIdx >= V.length rgs
    then Left "Parquet.PageIndex: row group index out of range"
    else do
      let cs = rgColumns (V.unsafeIndex rgs rgIdx)
      if colIdx < 0 || colIdx >= V.length cs
        then Left "Parquet.PageIndex: column index out of range"
        else do
          let chunk = V.unsafeIndex cs colIdx
          case ccFilePath chunk of
            Just path | path /= mempty -> Left "Parquet.PageIndex: external column-chunk files are not supported"
            _ -> Right chunk


-- Check the complete index, including entries the predicate will skip.
-- Data-page bytes are deliberately left untouched until a page is retained.
validateColumnOffsets :: ParquetFile -> Int -> Int -> OffsetIndex -> Either String ()
validateColumnOffsets pf groupIndex columnIndex index = do
  chunk <- selectColumnChunk pf groupIndex columnIndex
  metadata <- maybe (Left "Parquet.PageIndex: missing column metadata") Right (ccMetadata chunk)
  bytes <- rawColumnChunkSlice pf groupIndex columnIndex
  let group = fmRowGroups (pfFooter pf) V.! groupIndex
      rows = rgNumRows group
      locations = oiPageLocations index
      start = maybe (cmDataPageOffset metadata) (min (cmDataPageOffset metadata)) (cmDictionaryPageOffset metadata)
      size = if cmNumValues metadata == 0 then cmTotalCompressedSize metadata else fromIntegral (BS.length bytes)
      fileSize = fromIntegral (BS.length (pfBytes pf)) :: Int64
  if rows < 0 || start < 0 || start > fileSize || size < 0 || size > fileSize - start
    then Left "Parquet.PageIndex: invalid column bounds"
    else Right ()
  if V.null locations && rows /= 0
    then Left "Parquet.PageIndex: nonempty group has no indexed pages"
    else Right ()
  V.imapM_
    ( \i location -> do
        let offset = plOffset location
            pageSize = fromIntegral (plCompressedPageSize location) :: Int64
            row = plFirstRowIndex location
        if offset < start
          || offset > start + size
          || pageSize <= 0
          || pageSize > start + size - offset
          || row < 0
          || row >= rows && not (rows == 0 && i == 0)
          || i == 0 && (row /= 0 || offset /= cmDataPageOffset metadata)
          then Left "Parquet.PageIndex: page location lies outside its column/row range"
          else Right ()
        if i == 0
          then Right ()
          else do
            let previous = locations V.! (i - 1)
            if plOffset previous + fromIntegral (plCompressedPageSize previous) > offset
              || plFirstRowIndex previous >= row
              then Left "Parquet.PageIndex: unordered or overlapping page locations"
              else Right ()
    )
    locations
  case oiUnencodedByteArrayDataBytes index of
    Just sizes
      | V.length sizes /= V.length locations || V.any (< 0) sizes ->
          Left "Parquet.PageIndex: invalid per-page unencoded byte counts"
    _ -> Right ()


sliceFile
  :: ParquetFile
  -> Int64
  -> Int32
  -> String
  -> Either String ByteString
sliceFile pf off len what =
  let !o = fromIntegral off :: Int
      !l = fromIntegral len :: Int
      !bs = pfBytes pf
  in if o < 0 || l < 0 || o + l > BS.length bs
       then Left $ "Parquet.PageIndex: " ++ what ++ " slice out of bounds"
       else Right $! BS.take l (BS.drop o bs)


requireField
  :: [(Int16, TV.Value)]
  -> String
  -> ((Int16, TV.Value) -> Maybe a)
  -> Either String a
requireField fm name probe = case findField fm probe of
  Just v -> Right v
  Nothing -> Left $ "Parquet.PageIndex: missing or invalid field " ++ name


expectI64 :: TV.Value -> Either String Int64
expectI64 (TV.I64 v) = Right v
expectI64 v =
  Left $
    "Parquet.PageIndex: expected i64 but got " ++ show v


expectBool :: TV.Value -> Either String Bool
expectBool (TV.Bool b) = Right b
expectBool v =
  Left $
    "Parquet.PageIndex: expected bool but got " ++ show v


-- Thrift Compact transports binary and UTF-8 strings under the same
-- @TT_STRING@ wire type, so the decoder may surface either shape.
expectBinary :: TV.Value -> Either String ByteString
expectBinary (TV.Binary b) = Right b
expectBinary (TV.String t) =
  Right $! Data.Text.Encoding.encodeUtf8 t
expectBinary v =
  Left $
    "Parquet.PageIndex: expected binary but got " ++ show v