packages feed

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

{-# LANGUAGE LambdaCase #-}

{- | Aggregate pushdown for Parquet.

Many analytical queries reduce to one of:

  * @count(*)@ — number of rows in the whole file.
  * @count(col)@ — number of non-null rows in a column.
  * @min/max(col)@ — exact extrema of a column's defined values.

Counts and exact extrema can be answered from validated footer metadata
when the necessary statistics are available. Compact bounds are useful
for pruning, but cannot establish an exact aggregate. Floating-point
extrema require a known zero NaN count; scan when another NaN policy is
needed. Repeated leaves cannot supply a non-null /row/ count.

These functions trust the supplied statistics after checking their shape;
callers needing to verify a writer's claims must scan the data.
-}
module Parquet.Aggregate (
  -- * count(*)
  fileRowCount,
  fileRowCountChecked,
  rowGroupRowCount,

  -- * count(col)
  columnNonNullCount,

  -- * min / max
  columnMin,
  columnMax,

  -- * Note on @sum(col)@
  -- $sum
) where

-- \$sum
--
-- Parquet's @Statistics@ message intentionally doesn't carry
-- per-row-group sums (the spec authors decided sums are too
-- easy to overflow + lose precision to be safely cached). To
-- compute @sum(col)@ from a Parquet file you have to scan the
-- column values; there is no pushdown helper.
--
-- ORC /does/ carry @IntegerStatistics.sum@ — see
-- "ORC.Aggregate.columnSum" for the parallel helper that
-- works with ORC files.

import Columnar.Predicate qualified as Pred
import Control.Monad (guard)
import Data.Int (Int64)
import Data.Vector qualified as V
import Parquet.Levels qualified as Levels
import Parquet.Predicate qualified as PPred
import Parquet.Types qualified as P


{- | Sum of the row-group counts, retaining compatibility with older files
whose file-wide total is stale. Negative or overflowing group counts raise
an error; 'fileRowCountChecked' safely checks both the groups and file total.
-}
fileRowCount :: P.FileMetadata -> Int64
fileRowCount = maybe (error "Parquet.Aggregate: negative or overflowing row counts") id . rowGroupSum


-- | Reject negative, overflowing or inconsistent file/row-group counts.
fileRowCountChecked :: P.FileMetadata -> Maybe Int64
fileRowCountChecked fm = do
  total <- rowGroupSum fm
  guard (total == P.fmNumRows fm)
  Just total


rowGroupSum :: P.FileMetadata -> Maybe Int64
rowGroupSum fm = V.foldM addRows 0 (P.fmRowGroups fm)
  where
    addRows total group = do
      let rows = P.rgNumRows group
      guard (rows >= 0 && rows <= maxBound - total)
      Just (total + rows)


-- | Row count of one row group.
rowGroupRowCount :: P.RowGroup -> Int64
rowGroupRowCount = P.rgNumRows


{- | @count(col)@ — non-null rows for a non-repeated leaf, including
nullable ancestors. Returns 'Nothing' for repeated paths, missing counts,
or inconsistent metadata. Definition events equal rows only for paths
without repeated ancestors.
-}
columnNonNullCount :: P.FileMetadata -> Int -> Maybe Int64
columnNonNullCount fm colIdx = do
  _ <- fileRowCountChecked fm
  context <- PPred.statisticsContext fm colIdx
  V.foldM (step context) 0 (P.fmRowGroups fm)
  where
    step context acc group = do
      column <- P.rgColumns group V.!? colIdx
      metadata <- P.ccMetadata column
      guard (P.seType (PPred.statisticsField context) == Just (P.cmType metadata))
      (maxRep, maxDef) <-
        either
          (const Nothing)
          Just
          (Levels.maxLevelsForColumnPath (P.fmSchema fm) (P.cmPathInSchema metadata))
      guard (maxRep == 0 && P.cmNumValues metadata == P.rgNumRows group)
      if P.rgNumRows group == 0
        then Just acc
        else do
          statistics <- P.cmStatistics metadata
          nulls <- P.statNullCount statistics
          guard (nulls >= 0 && nulls <= P.rgNumRows group && (maxDef /= 0 || nulls == 0))
          let present = P.rgNumRows group - nulls
          guard (acc <= maxBound - present)
          Just (acc + present)


{- | Minimum value of a column across the whole file. Reads
each row group's @min_value@ (or legacy @min@) statistic
and folds them under the 'PValue' ordering. Returns
'Nothing' if /any/ contributing row group lacks min stats —
otherwise the result might be the min over a strict subset
of the row groups.
-}
columnMin :: P.FileMetadata -> Int -> Maybe Pred.PValue
columnMin = aggCmp takeMinStat (\acc v -> if Pred.pvLess v acc then v else acc)


{- | Maximum value of a column across the whole file. Same
soundness rule as 'columnMin': any row group without max
stats poisons the result to 'Nothing'.
-}
columnMax :: P.FileMetadata -> Int -> Maybe Pred.PValue
columnMax = aggCmp takeMaxStat (\acc v -> if Pred.pvLess acc v then v else acc)


{- | Common driver for column-wise stat aggregation. Polls the
supplied stat extractor on every row group; if any returns
'Nothing' the whole result is 'Nothing' (we can't be sound
about a min / max otherwise).
-}
aggCmp
  :: (PPred.StatisticsContext -> P.Statistics -> Maybe Pred.PValue)
  -> (Pred.PValue -> Pred.PValue -> Pred.PValue)
  -> P.FileMetadata
  -> Int
  -> Maybe Pred.PValue
aggCmp takeStat combine fm colIdx = do
  _ <- fileRowCountChecked fm
  context <- PPred.statisticsContext fm colIdx
  V.foldM (step context) Nothing (P.fmRowGroups fm) >>= id
  where
    step context acc group = do
      column <- P.rgColumns group V.!? colIdx
      metadata <- P.ccMetadata column
      guard (P.seType (PPred.statisticsField context) == Just (P.cmType metadata))
      (maxRep, _) <-
        either
          (const Nothing)
          Just
          (Levels.maxLevelsForColumnPath (P.fmSchema fm) (P.cmPathInSchema metadata))
      guard
        ( if maxRep == 0
            then P.cmNumValues metadata == P.rgNumRows group
            else P.cmNumValues metadata >= P.rgNumRows group
        )
      if P.rgNumRows group == 0
        then do
          guard (P.cmNumValues metadata == 0)
          Just acc
        else do
          statistics <- P.cmStatistics metadata
          guard (maybe True (\n -> n >= 0 && n <= P.cmNumValues metadata) (P.statNullCount statistics))
          if P.statNullCount statistics == Just (P.cmNumValues metadata)
            then Just acc
            else do
              guard (nanFree (PPred.statisticsField context) statistics)
              value <- takeStat context statistics
              Just (Just (maybe value (`combine` value) acc))

    nanFree field statistics
      | P.seType field `elem` [Just P.PTFloat, Just P.PTDouble]
          || P.seLogicalType field == Just P.LTFloat16 =
          P.statNanCount statistics == Just 0
      | otherwise = True


takeMinStat :: PPred.StatisticsContext -> P.Statistics -> Maybe Pred.PValue
takeMinStat context statistics =
  PPred.minimumStatistic
    context
    (if P.statMinValueExact statistics == Just True then statistics else statistics {P.statMinValue = Nothing})


takeMaxStat :: PPred.StatisticsContext -> P.Statistics -> Maybe Pred.PValue
takeMaxStat context statistics =
  PPred.maximumStatistic
    context
    (if P.statMaxValueExact statistics == Just True then statistics else statistics {P.statMaxValue = Nothing})