packages feed

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

{-# LANGUAGE BangPatterns #-}
{-# LANGUAGE LambdaCase #-}

{- | Predicate pushdown helpers for Parquet readers.

The Parquet spec already gives the reader several places to
avoid touching column data:

  * Row-group 'Statistics' carry @min_value@ / @max_value@ /
    @null_count@ for every column chunk.
  * Page-level 'ColumnIndex' carries the same triple per page,
    plus a 'BoundaryOrder' hint and an optional null-pages
    bitmap.
  * Per-column 'BloomFilter' (Sbbf) lets the reader prove a
    value is /not/ present in the column chunk.

This module bundles the three signals behind a single
'Predicate' ADT so callers can express filters once and ask
the planner whether a row group / page can be skipped without
decoding it.

The vocabulary is deliberately narrow — equality, range, and
membership over the same scalar value space (@PValue@) the
writer's statistics use. Compound boolean structure
(@'PAnd'@, @'POr'@, @'PNot'@) composes up from there.

@
import qualified Parquet.Predicate as Pred

let !p = 'PAnd' ('PCol' "ts" ('PGtEq' ('PVInt64' 1700000000)))
               ('PCol' "ts" ('PLt'   ('PVInt64' 1700100000)))
skipRowGroup :: 'P.RowGroup' -> Bool
skipRowGroup rg = 'evalRowGroup' fileMetadata p rg == 'PSkip'
@

The evaluator never produces a /false negative/ skip
(returning 'PSkip' when in fact some rows match) — if there's
any uncertainty (missing statistics, types we don't compare)
the answer is 'PMaybeKeep' and the caller falls back to
decoding the column chunk normally.
-}
module Parquet.Predicate (
  -- * Predicate vocabulary

  --

  {- | Re-exported from "Columnar.Predicate" so the same
  @PValue@ / @PColPredicate@ / @Predicate@ surface drives
  skip decisions in both Parquet and ORC.
  -}
  Predicate (..),
  PColPredicate (..),
  PValue (..),

  -- * Evaluation results
  Decision (..),
  combineDecisions,
  StatisticsContext (..),
  statisticsContext,

  -- * Statistics-based skipping
  evalRowGroup,
  evalColumnChunk,

  -- * Page-index-based skipping
  evalPagesByColumnIndex,

  -- * Bloom-filter membership
  evalBloomChunk,

  -- * Conversions
  decodePValueLE,
  decodePValueForSchema,
  minimumStatistic,
  maximumStatistic,
) where

import Arrow.Column qualified as AC
import Columnar.Predicate (
  Decision (..),
  PColPredicate (..),
  PValue (..),
  Predicate (..),
  combineDecisions,
  evalRange,
 )
import Data.Bits (shiftL, (.|.))
import Data.ByteString (ByteString)
import Data.ByteString qualified as BS
import Data.Int (Int32, Int64)
import Data.Ratio (denominator, numerator)
import Data.Text (Text)
import Data.Text qualified as T
import Data.Text.Encoding qualified as TE
import Data.Vector qualified as V
import Data.Word (Word32, Word64)
import GHC.Float (castDoubleToWord64, castFloatToWord32, castWord32ToFloat, castWord64ToDouble)
import Parquet.BloomFilter qualified as Bloom
import Parquet.Decimal (decodeDecimalInteger)
import Parquet.Types qualified as P


-- ============================================================
-- Statistics-based skipping
-- ============================================================

{- | Logical type plus the file's declared ordering. Missing orders cannot
give meaning to modern bounds; well-defined legacy numeric bounds remain
usable independently of this optional declaration.
-}
data StatisticsContext = StatisticsContext
  { statisticsField :: !P.SchemaElement
  , statisticsOrder :: !(Maybe P.ColumnOrder)
  }
  deriving (Show, Eq)


statisticsContext :: P.FileMetadata -> Int -> Maybe StatisticsContext
statisticsContext metadata index = do
  field <- V.filter (maybe False (const True) . P.seType) (P.fmSchema metadata) V.!? index
  Just (StatisticsContext field (P.fmColumnOrders metadata >>= (V.!? index)))


modernOrderSupported :: StatisticsContext -> Bool
modernOrderSupported context =
  P.hasDefinedStatisticsOrder field && case statisticsOrder context of
    Just P.TypeDefinedOrder -> P.seType field /= Just P.PTInt96
    Just P.IEEE754TotalOrder ->
      P.seType field `elem` [Just P.PTFloat, Just P.PTDouble]
        || P.seLogicalType field == Just P.LTFloat16
    Just P.Int96TimestampOrder -> P.seType field == Just P.PTInt96
    _ -> False
  where
    field = statisticsField context


{- | Decide whether an entire row group can be skipped.

Walks the predicate tree, looking up each leaf's column by
column path in the file schema. The schema retains logical annotations
so decimal and unsigned bounds use their numeric ordering. Missing columns / missing stats degrade
gracefully to 'PMaybeKeep'.
-}
evalRowGroup
  :: P.FileMetadata
  -- ^ File schema including logical annotations; nested columns use dotted paths.
  -> Predicate
  -> P.RowGroup
  -> Decision
evalRowGroup metadata p rg = evalPredicate lookupStats p
  where
    leaves = V.filter (maybe False (const True) . P.seType) (P.fmSchema metadata)
    lookupStats name = do
      let matches =
            V.findIndices
              ( \column -> case P.ccMetadata column of
                  Just columnMetadata ->
                    P.cmPathInSchema columnMetadata == V.singleton name
                      || name == T.intercalate "." (V.toList (P.cmPathInSchema columnMetadata))
                  _ -> False
              )
              (P.rgColumns rg)
      index <-
        if V.length leaves == V.length (P.rgColumns rg) && V.length matches == 1
          then Just (V.head matches)
          else Nothing
      context <- statisticsContext metadata index
      let field = statisticsField context
      column <- P.rgColumns rg V.!? index
      columnMetadata <- P.ccMetadata column
      statistics <- P.cmStatistics columnMetadata
      if P.seType field == Just (P.cmType columnMetadata)
        then Just (context, statistics)
        else Nothing


{- | Decide whether one column chunk can be skipped, given the
predicate fragment that applies to its column. Useful when
the caller has already projected the predicate per column.
-}
evalColumnChunk
  :: StatisticsContext
  -> P.ColumnChunk
  -> PColPredicate
  -> Decision
evalColumnChunk context cc cp = case P.ccMetadata cc of
  Nothing -> PMaybeKeep
  Just md -> case P.cmStatistics md of
    Nothing -> PMaybeKeep
    Just stats | P.seType (statisticsField context) == Just (P.cmType md) -> evalLeaf context stats cp
    _ -> PMaybeKeep


evalPredicate
  :: (Text -> Maybe (StatisticsContext, P.Statistics))
  -> Predicate
  -> Decision
evalPredicate _ PTrue = PMaybeKeep
evalPredicate _ PFalse = PSkip
evalPredicate look (PCol name cp) = case look name of
  Nothing -> PMaybeKeep
  Just (ty, stats) -> evalLeaf ty stats cp
evalPredicate look (PAnd a b) =
  combineDecisions (evalPredicate look a) (evalPredicate look b)
evalPredicate look (POr a b) =
  -- For OR we can only skip if /both/ disjuncts skip. The
  -- conservative answer otherwise is 'PMaybeKeep'.
  case (evalPredicate look a, evalPredicate look b) of
    (PSkip, PSkip) -> PSkip
    _ -> PMaybeKeep
evalPredicate look (PNot inner) = case evalPredicate look inner of
  -- Negation can't be pushed in general (a 'PSkip' on the
  -- inner predicate doesn't imply skipping for the negation).
  -- The conservative answer is always 'PMaybeKeep'.
  _ -> case inner of
    PFalse -> PMaybeKeep
    _ -> PMaybeKeep


-- ============================================================
-- Leaf predicates
-- ============================================================

{- | Evaluate one column-leaf predicate against the column
chunk's 'Statistics'. The 'ParquetType' tells us how to
decode the bytes inside @statMinValue@ / @statMaxValue@.
-}
evalLeaf :: StatisticsContext -> P.Statistics -> PColPredicate -> Decision
evalLeaf _ stats PIsNull
  -- IS NULL is skippable iff null_count is known to be zero.
  | P.statNullCount stats == Just 0 = PSkip
  | otherwise = PMaybeKeep
evalLeaf _ stats PIsNotNull
  -- IS NOT NULL is skippable iff every row is null. Without a
  -- per-chunk row count we can't be sure; fall back to maybe.
  | otherwise = case P.statNullCount stats of
      Just _ -> PMaybeKeep -- conservative
      Nothing -> PMaybeKeep
evalLeaf field stats cp =
  case (minimumStatistic field stats, maximumStatistic field stats) of
    (Just mn, Just mx) -> evalRange mn mx cp
    _ -> PMaybeKeep


minimumStatistic :: StatisticsContext -> P.Statistics -> Maybe PValue
minimumStatistic field stats = statisticValue field (P.statMinValue stats) (P.statMin stats)


maximumStatistic :: StatisticsContext -> P.Statistics -> Maybe PValue
maximumStatistic field stats = statisticValue field (P.statMaxValue stats) (P.statMax stats)


statisticValue :: StatisticsContext -> Maybe ByteString -> Maybe ByteString -> Maybe PValue
statisticValue context current legacy =
  ( case current of
      Just value | modernOrderSupported context -> Just value
      _ | legacyOrderMatches -> legacy
      _ -> Nothing
  )
    >>= decodePValueForSchema field
  where
    field = statisticsField context
    legacyOrderMatches =
      P.seType field `elem` (Just <$> [P.PTBoolean, P.PTInt32, P.PTInt64, P.PTFloat, P.PTDouble]) && case P.seLogicalType field of
        Just (P.LTInteger _ False) -> False
        Just P.LTDecimal {} -> P.seType field `elem` [Just P.PTInt32, Just P.PTInt64]
        _ -> P.seConvertedType field `notElem` (Just <$> [P.CTUInt8, P.CTUInt16, P.CTUInt32, P.CTUInt64, P.CTDecimal])


-- ============================================================
-- Min / max decoding
-- ============================================================

{- | Decode a min/max byte payload according to its
'P.ParquetType'. Mirrors what the writer's
@statisticsFor*@ helpers in "Parquet.Write" produced.
-}
decodePValueLE :: P.ParquetType -> ByteString -> Maybe PValue
decodePValueLE ty bs = case ty of
  P.PTBoolean ->
    case BS.uncons bs of
      Just (b, rest) | BS.null rest && b <= 1 -> Just (PVBool (b /= 0))
      Just _ -> Nothing
      Nothing -> Nothing
  P.PTInt32
    | BS.length bs == 4 ->
        Just (PVInt32 (fromIntegral (readLE32 bs 0)))
  P.PTInt64
    | BS.length bs == 8 ->
        Just (PVInt64 (fromIntegral (readLE64 bs 0)))
  P.PTFloat
    | BS.length bs == 4 ->
        let value = castWord32ToFloat (readLE32 bs 0)
        in if isNaN value then Nothing else Just (PVFloat value)
  P.PTDouble
    | BS.length bs == 8 ->
        let value = castWord64ToDouble (readLE64 bs 0)
        in if isNaN value then Nothing else Just (PVDouble value)
  P.PTByteArray ->
    -- Stats store the bytes without the PLAIN length prefix
    -- (per spec). Treat them as opaque binary; consumers can
    -- coerce to text if they know the column is UTF-8.
    Just (PVBinary bs)
  P.PTFixedLenByteArray ->
    Just (PVBinary bs)
  _ -> Nothing


{- | Decode statistics using the logical annotation as well as its carrier.
Exact decimal scalars cover the Arrow decimal256 precision range (76 digits).
-}
decodePValueForSchema :: P.SchemaElement -> ByteString -> Maybe PValue
decodePValueForSchema field bytes
  | not (P.hasDefinedStatisticsOrder field) = Nothing
  | otherwise = case P.seLogicalType field of
      Just P.LTFloat16
        | BS.length bytes == 2 ->
            let bits = fromIntegral (BS.index bytes 0) .|. (fromIntegral (BS.index bytes 1) `shiftL` 8)
                value = AC.float16ToDouble (AC.Float16 bits)
            in if isNaN value then Nothing else Just (PVDouble value)
      Just P.LTFloat16 -> Nothing
      Just P.LTTimestamp {} | P.seType field /= Just P.PTInt64 -> Nothing
      Just (P.LTDecimal precision scale)
        | precision > 0 && precision <= 76 && scale >= 0 && scale <= precision -> do
            value <- case P.seType field of
              Just P.PTInt32 | BS.length bytes == 4 -> Just (fromIntegral (fromIntegral (readLE32 bytes 0) :: Int32))
              Just P.PTInt64 | BS.length bytes == 8 -> Just (fromIntegral (fromIntegral (readLE64 bytes 0) :: Int64))
              Just P.PTByteArray -> decodeDecimalInteger bytes
              Just P.PTFixedLenByteArray -> decodeDecimalInteger bytes
              _ -> Nothing
            Just (PVDecimal (fromInteger value / fromInteger (10 ^ scale)))
        | otherwise -> Nothing
      _
        | unsigned -> case P.seType field of
            Just P.PTInt32 | BS.length bytes == 4 -> Just (PVUnsigned (fromIntegral (readLE32 bytes 0)))
            Just P.PTInt64 | BS.length bytes == 8 -> Just (PVUnsigned (readLE64 bytes 0))
            _ -> Nothing
        | text -> either (const Nothing) (Just . PVText) (TE.decodeUtf8' bytes)
        | otherwise -> P.seType field >>= (`decodePValueLE` bytes)
  where
    unsigned = case P.seLogicalType field of
      Just (P.LTInteger _ signed) -> not signed
      _ -> P.seConvertedType field `elem` (Just <$> [P.CTUInt8, P.CTUInt16, P.CTUInt32, P.CTUInt64])
    text =
      P.seType field == Just P.PTByteArray
        && ( P.seLogicalType field `elem` [Just P.LTString, Just P.LTEnum, Just P.LTJson]
               || P.seConvertedType field `elem` [Just P.CTUtf8, Just P.CTEnum, Just P.CTJson]
           )


readLE32 :: ByteString -> Int -> Word32
readLE32 bs o =
  let b0 = fromIntegral (BS.index bs o) :: Word32
      b1 = fromIntegral (BS.index bs (o + 1)) :: Word32
      b2 = fromIntegral (BS.index bs (o + 2)) :: Word32
      b3 = fromIntegral (BS.index bs (o + 3)) :: Word32
  in b0 .|. (b1 `shiftL` 8) .|. (b2 `shiftL` 16) .|. (b3 `shiftL` 24)


readLE64 :: ByteString -> Int -> Word64
readLE64 bs o =
  let lo = fromIntegral (readLE32 bs o) :: Word64
      hi = fromIntegral (readLE32 bs (o + 4)) :: Word64
  in lo .|. (hi `shiftL` 32)


-- ============================================================
-- Page-index-based skipping
-- ============================================================

{- | Per-page skip decisions for one column chunk, derived from
the chunk's 'P.ColumnIndex'. The result vector is parallel
to @ciNullPages@ / @ciMinValues@ / @ciMaxValues@: an entry
of 'PSkip' means the corresponding 'P.PageLocation' (in the
chunk's matching 'P.OffsetIndex') can be skipped without
decoding.

Pages whose @null_pages@ slot is 'True' (the page contains
only nulls) are skipped for /any/ value-style predicate
('PEq', 'PLt', …); 'PIsNull' keeps them.
-}
evalPagesByColumnIndex
  :: StatisticsContext
  -> P.ColumnIndex
  -> PColPredicate
  -> V.Vector Decision
evalPagesByColumnIndex context ci cp =
  let !nulls = P.ciNullPages ci
      !mins = P.ciMinValues ci
      !maxs = P.ciMaxValues ci
      !n = V.length nulls
  in if V.length mins /= n || V.length maxs /= n
       then V.replicate n PMaybeKeep
       else V.generate n $ \i ->
         let !isNull = V.unsafeIndex nulls i
         in if isNull
              then case cp of
                PIsNull -> PMaybeKeep
                PIsNotNull -> PSkip
                _ -> PSkip
              else case ( decodeBound (V.unsafeIndex mins i)
                        , decodeBound (V.unsafeIndex maxs i)
                        ) of
                (Just mn, Just mx) -> evalRange mn mx cp
                _ -> PMaybeKeep
  where
    decodeBound bytes
      | modernOrderSupported context = decodePValueForSchema (statisticsField context) bytes
      | otherwise = Nothing


-- ============================================================
-- Bloom-filter membership
-- ============================================================

{- | Probe a column chunk's bloom filter for membership of the
value(s) referenced by the predicate. Returns 'PSkip' when
the bloom proves the column /cannot/ contain any matching
value; 'PMaybeKeep' otherwise.

Only @PEq@ and @PIn@ are bloom-checkable; other predicates
always degrade to 'PMaybeKeep' here (range / nullity have
to use min/max stats instead).
-}
evalBloomChunk
  :: P.SchemaElement
  -> Bloom.Sbbf
  -> PColPredicate
  -> Decision
evalBloomChunk field sbbf = \case
  PEq v -> case bloomCheckVal field sbbf v of
    True -> PMaybeKeep
    False -> PSkip
  PIn vs ->
    -- Skip iff every candidate is rejected by the filter.
    if all (not . bloomCheckVal field sbbf) vs
      then PSkip
      else PMaybeKeep
  _ -> PMaybeKeep


{- | Bloom-filter check for one PValue. Encodes the value into
its PLAIN-form byte payload (matching what the writer
inserts) and probes the filter.
-}
bloomCheckVal :: P.SchemaElement -> Bloom.Sbbf -> PValue -> Bool
bloomCheckVal field sbbf value = case value of
  PVFloat f | f == 0 -> check (PVFloat 0.0) || check (PVFloat (-0.0))
  PVDouble d | d == 0 -> check (PVDouble 0.0) || check (PVDouble (-0.0))
  _ -> check value
  where
    -- Unknown representations cannot prove absence. Variable-width decimal
    -- bytes may have redundant sign extension, so one hash is insufficient.
    check v = maybe True (\bytes -> Bloom.sbbfCheck bytes sbbf) (encodePlain field v)


encodePlain :: P.SchemaElement -> PValue -> Maybe ByteString
encodePlain field value
  | not (P.hasCompatiblePhysicalType field) = Nothing
  | Just P.LTDecimal {} <- P.seLogicalType field
  , notDecimal value =
      Nothing
  | otherwise = case (P.seType field, value) of
      (Just P.PTInt32, PVInt32 n) -> Just (word32LE (fromIntegral n))
      (Just P.PTInt64, PVInt64 n) -> Just (word64LE (fromIntegral n))
      (Just P.PTInt32, PVUnsigned n) | unsigned && n <= fromIntegral (maxBound :: Word32) -> Just (word32LE (fromIntegral n))
      (Just P.PTInt64, PVUnsigned n) | unsigned -> Just (word64LE n)
      (Just P.PTFloat, PVFloat f) -> Just (word32LE (castFloatToWord32 f))
      (Just P.PTDouble, PVDouble d) -> Just (word64LE (castDoubleToWord64 d))
      (Just P.PTBoolean, PVBool b) -> Just (BS.singleton (if b then 1 else 0))
      (Just P.PTByteArray, PVText t) -> Just (TE.encodeUtf8 t)
      (Just P.PTByteArray, PVBinary bytes) -> Just bytes
      (Just P.PTFixedLenByteArray, PVBinary bytes) -> Just bytes
      (_, PVDecimal decimal) -> case P.seLogicalType field of
        Just (P.LTDecimal _ scale)
          | scale >= 0 && scale <= 76 ->
              let scaled = decimal * fromInteger (10 ^ scale)
                  integer = numerator scaled
              in if denominator scaled /= 1
                   then Nothing
                   else case P.seType field of
                     Just P.PTInt32
                       | integer >= fromIntegral (minBound :: Int32) && integer <= fromIntegral (maxBound :: Int32) ->
                           Just (word32LE (fromIntegral integer))
                     Just P.PTInt64
                       | integer >= fromIntegral (minBound :: Int64) && integer <= fromIntegral (maxBound :: Int64) ->
                           Just (word64LE (fromIntegral integer))
                     _ -> Nothing
        _ -> Nothing
      _ -> Nothing
  where
    notDecimal PVDecimal {} = False
    notDecimal _ = True
    unsigned = case P.seLogicalType field of
      Just (P.LTInteger _ signed) -> not signed
      _ -> P.seConvertedType field `elem` (Just <$> [P.CTUInt8, P.CTUInt16, P.CTUInt32, P.CTUInt64])


word32LE :: Word32 -> ByteString
word32LE w =
  BS.pack
    [ fromIntegral w
    , fromIntegral (w `quot` 0x100)
    , fromIntegral (w `quot` 0x10000)
    , fromIntegral (w `quot` 0x1000000)
    ]


word64LE :: Word64 -> ByteString
word64LE w =
  BS.pack
    [ fromIntegral w
    , fromIntegral (w `quot` 0x100)
    , fromIntegral (w `quot` 0x10000)
    , fromIntegral (w `quot` 0x1000000)
    , fromIntegral (w `quot` 0x100000000)
    , fromIntegral (w `quot` 0x10000000000)
    , fromIntegral (w `quot` 0x1000000000000)
    , fromIntegral (w `quot` 0x100000000000000)
    ]