dataframe-parquet-1.6.0.0: src/DataFrame/IO/Parquet.hs
{-# LANGUAGE AllowAmbiguousTypes #-}
{-# LANGUAGE CPP #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE MonoLocalBinds #-}
{-# LANGUAGE NumericUnderscores #-}
{-# LANGUAGE OverloadedRecordDot #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TypeApplications #-}
module DataFrame.IO.Parquet (
-- * Reading
readParquet,
readParquetWithOpts,
readParquetFiles,
readParquetFilesWithOpts,
-- * Writing
writeParquet,
writeParquetWithOptions,
-- * Options
ParquetReadOptions (..),
defaultParquetReadOptions,
ParquetWriteOptions (..),
WriterStrategy (..),
defaultParquetWriteOptions,
-- * File metadata
parseFileMetadata,
readMetadataFromPath,
readMetadataFromHandle,
columnChunksForAll,
-- * Schema description
ColumnDescription (..),
generateColumnDescriptions,
getColumnNames,
-- * Decoding
parseParquetWithOpts,
parseColumnChunks,
getNonNullableColumn,
getNullableColumn,
getRepeatedColumn,
applyDescLogicalType,
applyLogicalType,
nativeTypeHints,
restoreNativeType,
-- * Applying read options to a decoded frame
applyReadOptions,
applyPredicate,
applySelectedColumns,
applyRowRange,
applySafeRead,
-- * Data sources
RandomAccess (..),
ReaderIO (runReaderIO),
FileBufferedOrSeekable,
ForceNonSeekable,
withFileBufferedOrSeekable,
_readParquetWithOpts,
readParquetWithMetadata,
FileMetadata,
) where
import Control.Concurrent (getNumCapabilities)
import Control.Exception (throw)
import Control.Monad
import Control.Monad.IO.Class (MonadIO (..))
import Control.Monad.ST (stToIO)
import Data.Bits (Bits (shiftL), (.|.))
import qualified Data.ByteString as BS
import Data.Either (fromRight)
import Data.Functor ((<&>))
import Data.IORef (atomicModifyIORef', newIORef)
import Data.Int (Int32, Int64)
import Data.Maybe (fromJust, fromMaybe)
#if !MIN_VERSION_base(4,20,0)
import Data.List (foldl', transpose)
#else
import Data.List (transpose)
#endif
import qualified Data.List as L
import qualified Data.Map as Map
import qualified Data.Text as T
import Data.Time.Calendar (Day (ModifiedJulianDay))
import Data.Time.Clock (UTCTime (UTCTime), picosecondsToDiffTime)
import Data.Type.Equality (testEquality, (:~:) (Refl))
import qualified Data.Vector as Vector
import qualified Data.Vector.Mutable as MVector
import qualified Data.Vector.Unboxed as VU
import qualified Data.Vector.Unboxed.Mutable as VUM
import DataFrame.Errors (DataFrameException (ColumnsNotFoundException))
import DataFrame.IO.Parquet.Dictionary (
DictVals,
dictDouble,
dictFloat,
dictInt32,
dictInt64,
word32At,
word64At,
)
import DataFrame.IO.Parquet.Page (
PageDecoder,
UnboxedPageDecoder,
appendNullableStringPageIO,
appendStringPageIO,
boolDecoder,
byteArrayDecoder,
decodeRequiredNumericInto,
doubleDecoder,
fixedLenByteArrayDecoder,
floatDecoder,
foldColumnDataPagesM,
foldColumnPagesM,
int32Decoder,
int64Decoder,
int96Decoder,
)
import DataFrame.IO.Parquet.Seeking (
FileBufferedOrSeekable (..),
ForceNonSeekable,
withFileBufferedOrSeekable,
)
import DataFrame.IO.Parquet.Thrift (
ColumnChunk (..),
ColumnMetaData (..),
DecimalType (..),
FileMetadata (..),
KeyValue (..),
LogicalType (..),
RowGroup (..),
Statistics (..),
ThriftType (..),
TimeUnit (..),
TimestampType (..),
unField,
)
import DataFrame.IO.Parquet.Utils (
ColumnDescription (..),
foldNonNullable,
foldNonNullableUnboxed,
foldNullable,
foldNullableUnboxed,
foldRepeated,
foldRepeatedUnboxed,
generateColumnDescriptions,
getColumnNames,
)
import DataFrame.IO.Parquet.Writer (
ParquetWriteOptions (..),
WriterStrategy (..),
defaultParquetWriteOptions,
nativeTypeKeyPrefix,
writeParquet,
writeParquetWithOptions,
)
import DataFrame.IO.Utils.RandomAccess (
ChunkBuffer (..),
RandomAccess (..),
Range (Range),
ReaderIO (runReaderIO),
)
import DataFrame.Internal.Column (Column, Columnable)
import qualified DataFrame.Internal.Column as DI
import DataFrame.Internal.Column.Builder (
concatColumnsIO,
freezeTextChunk,
mergeTextChunks,
newTextBuilder,
)
import DataFrame.Internal.Control.Concurrent (forkJoin_)
import DataFrame.Internal.DataFrame (DataFrame (..))
import DataFrame.Internal.Expression (
BinaryOp (binaryName),
Expr (..),
getColumns,
)
import DataFrame.Operations.Merge ()
import qualified DataFrame.Operations.Subset as DS
import DataFrame.Schema (
Schema (..),
SchemaType,
makeSchema,
schemaType,
)
import GHC.Float (castWord32ToFloat, castWord64ToDouble)
import qualified Pinch
import System.Directory (doesDirectoryExist)
import System.FilePath ((</>))
import System.FilePath.Glob (glob)
import System.IO (IOMode (ReadMode))
import Type.Reflection (typeRep)
-- | Options for reading Parquet data.
data ParquetReadOptions = ParquetReadOptions
{ selectedColumns :: Maybe [T.Text]
-- ^ Columns to keep in the final dataframe.
, predicate :: Maybe (Expr Bool)
-- ^ Optional row filter expression applied before projection.
, rowRange :: Maybe (Int, Int)
-- ^ Optional row slice @(start, end)@, start-inclusive, end-exclusive.
, safeColumns :: Bool
-- ^ Promote every column to OptionalColumn after read.
, widenIntegers :: Bool
-- ^ Decode INT32 and INT64 columns as 'Int'.
, parallel :: Bool
-- ^ Decode row groups concurrently using every RTS capability.
}
deriving (Show)
-- | Default Parquet read options.
defaultParquetReadOptions :: ParquetReadOptions
defaultParquetReadOptions =
ParquetReadOptions
{ selectedColumns = Nothing
, predicate = Nothing
, rowRange = Nothing
, safeColumns = False
, widenIntegers = False
, parallel = True
}
-- | Read a parquet file from path and load it into a dataframe.
readParquet :: FilePath -> IO DataFrame
readParquet = readParquetWithOpts defaultParquetReadOptions
-- | Read a Parquet file using explicit read options.
readParquetWithOpts :: ParquetReadOptions -> FilePath -> IO DataFrame
readParquetWithOpts = _readParquetWithOpts Nothing
-- | Internal entry point used by tests to force non-seekable mode.
_readParquetWithOpts ::
ForceNonSeekable -> ParquetReadOptions -> FilePath -> IO DataFrame
_readParquetWithOpts extraConfig opts path = do
metadata <-
withFileBufferedOrSeekable
extraConfig
path
ReadMode
(runReaderIO parseFileMetadata)
readParquetWithMetadata extraConfig metadata opts path
-- | Read a Parquet file whose footer has already been parsed.
readParquetWithMetadata ::
ForceNonSeekable ->
FileMetadata ->
ParquetReadOptions ->
FilePath ->
IO DataFrame
readParquetWithMetadata extraConfig metadata opts path = do
caps <- getNumCapabilities
let nRowGroups = length (unField metadata.row_groups)
if parallel opts && extraConfig /= Just True && caps > 1 && nRowGroups > 1
then decodeRowGroupsParallel caps opts path metadata
else withFileBufferedOrSeekable extraConfig path ReadMode $ \file ->
runReaderIO (parseParquetWithOpts opts) file
-- | Decode row groups in parallel, one task per (row group, column).
decodeRowGroupsParallel ::
Int -> ParquetReadOptions -> FilePath -> FileMetadata -> IO DataFrame
decodeRowGroupsParallel caps opts0 path metadata = do
let plan = planColumns opts0 metadata
descs = Vector.fromList (planDescs plan)
allRowGroupRows =
map (fromIntegral . unField . rg_num_rows) (unField metadata.row_groups)
allOffsets = scanl (+) 0 allRowGroupRows
-- Row groups whose statistics rule out the predicate are skipped.
statsGroups =
[ g
| (g, rg) <- zip [0 ..] (unField metadata.row_groups)
, maybe True (rowGroupMayMatch metadata rg) (predicate opts0)
]
-- A row range only touches overlapping groups, then is rebased.
(keptGroups, opts) = case rowRange opts0 of
Nothing -> (statsGroups, opts0)
Just (s, e) ->
let groups =
[ g
| (g, off, n) <- zip3 [0 ..] allOffsets allRowGroupRows
, off < e
, off + n > s
]
base = case groups of
[] -> 0
(g : _) -> allOffsets !! g
in (groups, opts0{rowRange = Just (s - base, e - base)})
chunks =
Vector.fromList
[Vector.fromList [cs !! g | g <- keptGroups] | cs <- planChunks plan]
rowGroupRows = Vector.fromList [allRowGroupRows !! g | g <- keptGroups]
rowOffsets = Vector.prescanl' (+) 0 rowGroupRows
totalRows = Vector.sum rowGroupRows
nCols = Vector.length descs
nRg = Vector.length rowGroupRows
nTasks = nCols * nRg
taskChunk t = let (r, c) = t `divMod` nCols in chunks Vector.! c Vector.! r
sinks <- Vector.mapM (newSink (widenIntegers opts) totalRows) descs
results <- MVector.new nTasks
counter <- newIORef (0 :: Int)
-- Each worker owns a handle, so chunk reads run in parallel too.
let worker = withFileBufferedOrSeekable Nothing path ReadMode $ \file ->
let loop = do
t <- atomicModifyIORef' counter (\n -> (n + 1, n))
when (t < nTasks) $ do
let (r, c) = t `divMod` nCols
desc = descs Vector.! c
chunk = taskChunk t
(base, len) = chunkRange chunk
bytes <- runReaderIO (readBytes (Range base len)) file
let buffer = ChunkBuffer base bytes
into ::
forall a.
(VU.Unbox a) =>
(DictVals -> Maybe (VU.Vector a)) ->
(BS.ByteString -> Int -> a) ->
Int ->
VUM.IOVector a ->
IO ()
into pick plainAt width out =
void $
runReaderIO
( decodeRequiredNumericInto
pick
plainAt
width
desc
chunk
out
(rowOffsets Vector.! r)
)
buffer
case sinks Vector.! c of
SinkInt out width ->
if width == 4
then into (fmap (VU.map fromIntegral) . dictInt32) plainIntFrom32 4 out
else into (fmap (VU.map fromIntegral) . dictInt64) plainIntFrom64 8 out
SinkInt32 out -> into dictInt32 plainInt32 4 out
SinkInt64 out -> into dictInt64 plainInt64 8 out
SinkFloat out -> into dictFloat plainFloat 4 out
SinkDouble out -> into dictDouble plainDouble 8 out
SinkRows -> do
col <-
runReaderIO
(parseColumnChunks (rowGroupRows Vector.! r) [chunk] desc)
buffer
MVector.write results (c * nRg + r) col
loop
in loop
forkJoin_ (replicate (max 1 (min caps nTasks)) worker)
cols <- forM [0 .. nCols - 1] $ \c -> case sinks Vector.! c of
SinkInt out _ -> DI.UnboxedColumn Nothing <$> VU.unsafeFreeze out
SinkInt32 out -> DI.UnboxedColumn Nothing <$> VU.unsafeFreeze out
SinkInt64 out -> DI.UnboxedColumn Nothing <$> VU.unsafeFreeze out
SinkFloat out -> DI.UnboxedColumn Nothing <$> VU.unsafeFreeze out
SinkDouble out -> DI.UnboxedColumn Nothing <$> VU.unsafeFreeze out
SinkRows ->
concatColumnsIO
=<< forM [0 .. nRg - 1] (\r -> MVector.read results (c * nRg + r))
pure (assembleDataFrame opts totalRows metadata plan cols)
-- | Where a column's decoded values go.
data ColumnSink
= -- | Widened integers; the width is the source's bytes per value.
SinkInt !(VUM.IOVector Int) !Int
| SinkInt32 !(VUM.IOVector Int32)
| SinkInt64 !(VUM.IOVector Int64)
| SinkFloat !(VUM.IOVector Float)
| SinkDouble !(VUM.IOVector Double)
| SinkRows
newSink :: Bool -> Int -> ColumnDescription -> IO ColumnSink
newSink widen totalRows description
| description.maxRepetitionLevel /= 0 || description.maxDefinitionLevel /= 0 =
pure SinkRows
| otherwise = case description.colElementType of
Just (INT32 _)
| widen -> (`SinkInt` 4) <$> VUM.unsafeNew totalRows
| otherwise -> SinkInt32 <$> VUM.unsafeNew totalRows
Just (INT64 _)
| widen -> (`SinkInt` 8) <$> VUM.unsafeNew totalRows
| otherwise -> SinkInt64 <$> VUM.unsafeNew totalRows
Just (FLOAT _) -> SinkFloat <$> VUM.unsafeNew totalRows
Just (DOUBLE _) -> SinkDouble <$> VUM.unsafeNew totalRows
_ -> pure SinkRows
plainIntFrom32 :: BS.ByteString -> Int -> Int
plainIntFrom32 bs o = fromIntegral (fromIntegral (word32At bs o) :: Int32)
{-# INLINE plainIntFrom32 #-}
plainIntFrom64 :: BS.ByteString -> Int -> Int
plainIntFrom64 bs o = fromIntegral (word64At bs o)
{-# INLINE plainIntFrom64 #-}
plainInt32 :: BS.ByteString -> Int -> Int32
plainInt32 bs o = fromIntegral (word32At bs o)
{-# INLINE plainInt32 #-}
plainInt64 :: BS.ByteString -> Int -> Int64
plainInt64 bs o = fromIntegral (word64At bs o)
{-# INLINE plainInt64 #-}
plainFloat :: BS.ByteString -> Int -> Float
plainFloat bs o = castWord32ToFloat (word32At bs o)
{-# INLINE plainFloat #-}
plainDouble :: BS.ByteString -> Int -> Double
plainDouble bs o = castWord64ToDouble (word64At bs o)
{-# INLINE plainDouble #-}
-- | Whether a row group can hold a row satisfying the predicate.
rowGroupMayMatch :: FileMetadata -> RowGroup -> Expr Bool -> Bool
rowGroupMayMatch metadata rg = go
where
allNames = getColumnNames (drop 1 (unField metadata.schema))
chunks = unField (rg_columns rg)
go :: Expr Bool -> Bool
go (Binary op l r)
| binaryName op == "and" = case (l, r) of
(l' :: Expr lt, r' :: Expr rt)
| Just Refl <- testEquality (typeRep @lt) (typeRep @Bool)
, Just Refl <- testEquality (typeRep @rt) (typeRep @Bool) ->
go l' && go r'
_ -> True
| otherwise = case (l, r) of
(Col name :: Expr lt, Lit (v :: rt))
| Just Refl <- testEquality (typeRep @lt) (typeRep @Int)
, Just Refl <- testEquality (typeRep @rt) (typeRep @Int) ->
decide name (binaryName op) v
(Lit (v :: lt), Col name :: Expr rt)
| Just Refl <- testEquality (typeRep @lt) (typeRep @Int)
, Just Refl <- testEquality (typeRep @rt) (typeRep @Int) ->
decide name (flipped (binaryName op)) v
_ -> True
go _ = True
flipped :: T.Text -> T.Text
flipped n = case n of
"lt" -> "gt"
"gt" -> "lt"
"leq" -> "geq"
"geq" -> "leq"
other -> other
-- @column <cmp> v@ can hold for some row in [lo, hi].
decide :: T.Text -> T.Text -> Int -> Bool
decide name cmp v = case columnRange name of
Nothing -> True
Just (lo, hi) -> case cmp of
"eq" -> lo <= v && v <= hi
"neq" -> not (lo == v && hi == v)
"lt" -> lo < v
"leq" -> lo <= v
"gt" -> hi > v
"geq" -> hi >= v
_ -> True
columnRange :: T.Text -> Maybe (Int, Int)
columnRange name = do
i <- L.findIndex (columnMatches allNames name) allNames
cc <- chunks `atMay` i
meta <- unField (cc_meta_data cc)
stats <- unField (cmd_statistics meta)
let decodeInt bs = case unField (cmd_type meta) of
INT32 _
| BS.length bs == 4 ->
Just (fromIntegral (fromIntegral (word32At bs 0) :: Int32))
INT64 _ | BS.length bs == 8 -> Just (fromIntegral (word64At bs 0))
_ -> Nothing
lo <-
decodeInt
=<< (unField (stats_min_value stats) `orElse` unField (stats_min stats))
hi <-
decodeInt
=<< (unField (stats_max_value stats) `orElse` unField (stats_max stats))
pure (lo, hi)
orElse (Just x) _ = Just x
orElse Nothing y = y
atMay xs i = if i >= 0 && i < length xs then Just (xs !! i) else Nothing
-- | File offset and byte length of a column chunk (dictionary page included).
chunkRange :: ColumnChunk -> (Integer, Int)
chunkRange columnChunk =
let meta = fromJust . unField $ columnChunk.cc_meta_data
dataOffset = fromIntegral . unField $ meta.cmd_data_page_offset
dictOffset = fromIntegral <$> unField meta.cmd_dictionary_page_offset
in ( fromMaybe dataOffset dictOffset
, fromIntegral . unField $ meta.cmd_total_compressed_size
)
-- | The columns a read decodes, after projection, in schema order.
data ColumnPlan = ColumnPlan
{ planNames :: [T.Text]
, planDescs :: [ColumnDescription]
, planChunks :: [[ColumnChunk]]
-- ^ Per column, one chunk per row group.
}
-- | Read Parquet files from a directory or glob path.
readParquetFiles :: FilePath -> IO DataFrame
readParquetFiles = readParquetFilesWithOpts defaultParquetReadOptions
-- | Read multiple Parquet files (directory or glob) using options.
readParquetFilesWithOpts :: ParquetReadOptions -> FilePath -> IO DataFrame
readParquetFilesWithOpts opts path = do
isDir <- doesDirectoryExist path
let pat = if isDir then path </> "*.parquet" else path
matches <- glob pat
files <- L.sort <$> filterM (fmap not . doesDirectoryExist) matches
case files of
[] ->
error $
"readParquetFiles: no parquet files found for " ++ path
_ -> do
let optsWithoutRowRange = opts{rowRange = Nothing}
dfs <- mapM (readParquetWithOpts optsWithoutRowRange) files
pure (applyRowRange opts (mconcat dfs))
-- | Parse a Parquet file via the 'RandomAccess' handle.
parseParquetWithOpts ::
(RandomAccess m, MonadIO m) =>
ParquetReadOptions ->
m DataFrame
{-# SPECIALIZE parseParquetWithOpts ::
ParquetReadOptions -> ReaderIO FileBufferedOrSeekable DataFrame
#-}
{-# INLINEABLE parseParquetWithOpts #-}
parseParquetWithOpts opts = do
metadata <- parseFileMetadata
let plan = planColumns opts metadata
vectorLength = fileRowCount metadata
rawCols <-
zipWithM (parseColumnChunks vectorLength) (planChunks plan) (planDescs plan)
return (assembleDataFrame opts vectorLength metadata plan rawCols)
-- | Row count from the footer, falling back to the sum of row-group counts.
fileRowCount :: FileMetadata -> Int
fileRowCount metadata =
let topLevelRows = fromIntegral . unField $ metadata.num_rows :: Int
rgRows =
sum $ map (fromIntegral . unField . rg_num_rows) (unField metadata.row_groups) ::
Int
in if topLevelRows > 0 then topLevelRows else rgRows
columnMatches :: [T.Text] -> T.Text -> T.Text -> Bool
columnMatches allNames requested name =
name == requested
|| (leafOf name == requested && requested `notElem` allNames)
-- | The last dot-separated segment of a column path.
leafOf :: T.Text -> T.Text
leafOf = last . T.splitOn "."
-- | Validate the requested columns against the schema and pick what to decode.
planColumns :: ParquetReadOptions -> FileMetadata -> ColumnPlan
planColumns opts metadata =
let schemaElems = unField metadata.schema
allNames = getColumnNames (drop 1 schemaElems)
knownNames = L.nub (allNames ++ map leafOf allNames)
predicateColumns = maybe [] (L.nub . getColumns) (predicate opts)
selectedColumnsForRead = case selectedColumns opts of
Nothing -> Nothing
Just selected -> Just (L.nub (selected ++ predicateColumns))
-- TODO: With rowRange set, skip row groups the range does not overlap.
-- TODO: Skip row groups whose statistics rule out the predicate.
-- Validate selected columns
validated = case selectedColumnsForRead of
Nothing -> ()
Just requested ->
let missing = [r | r <- requested, not (any (columnMatches allNames r) allNames)]
in if L.null missing
then ()
else
throw
( ColumnsNotFoundException
missing
"readParquetWithOpts"
knownNames
)
descriptions = generateColumnDescriptions schemaElems
chunks = columnChunksForAll metadata
nCols = length chunks
nDescs = length descriptions
counted =
if nCols == nDescs
then ()
else
error $
"Column count mismatch: got "
<> show nCols
<> " columns but schema implied "
<> show nDescs
<> " columns"
-- Decode only the columns needed for the output plus any predicate.
keep name = case selectedColumnsForRead of
Nothing -> True
Just req -> any (\r -> columnMatches allNames r name) req
kept = filter (\(n, _, _) -> keep n) (zip3 allNames chunks descriptions)
in validated `seq`
counted `seq`
ColumnPlan
{ planNames = [n | (n, _, _) <- kept]
, planDescs = [d | (_, _, d) <- kept]
, planChunks = [c | (_, c, _) <- kept]
}
-- | Apply logical types and read options to the decoded columns.
assembleDataFrame ::
ParquetReadOptions -> Int -> FileMetadata -> ColumnPlan -> [Column] -> DataFrame
assembleDataFrame opts totalRows metadata plan rawCols =
let hints = nativeTypeHints metadata
keptNames = planNames plan
widen col
| not (widenIntegers opts) = col
| otherwise = case col of
DI.UnboxedColumn Nothing (v :: VU.Vector t)
| Just Refl <- testEquality (typeRep @t) (typeRep @Int32) ->
DI.UnboxedColumn Nothing (VU.map (fromIntegral @Int32 @Int) v)
| Just Refl <- testEquality (typeRep @t) (typeRep @Int64) ->
DI.UnboxedColumn Nothing (VU.map (fromIntegral @Int64 @Int) v)
_ -> col
finalCols =
zipWith
(restoreNativeType hints)
keptNames
(map widen (zipWith applyDescLogicalType (planDescs plan) rawCols))
indices = Map.fromList $ zip keptNames [0 ..]
dimensions = (totalRows, length finalCols)
df =
DataFrame
(Vector.fromListN (length finalCols) finalCols)
indices
dimensions
Map.empty
in applyReadOptions opts df
-- | Parse the file-level Thrift metadata from the Parquet footer.
parseFileMetadata :: (RandomAccess m) => m FileMetadata
parseFileMetadata = do
footerBytes <- readSuffix 8
let magic = BS.drop 4 footerBytes
when (magic /= "PAR1") $
error
( "Not a valid Parquet file: expected magic bytes \"PAR1\", got "
++ show magic
)
let size = getMetadataSize footerBytes
rawMetadata <- readSuffix (size + 8) <&> BS.take size
case Pinch.decode Pinch.compactProtocol rawMetadata of
Left e -> error $ "Failed to parse Parquet metadata: " ++ show e
Right metadata -> return metadata
where
getMetadataSize footer =
let sizes :: [Int]
sizes = map (fromIntegral . BS.index footer) [0 .. 3]
in foldl' (.|.) 0 $ zipWith shiftL sizes [0, 8 .. 24]
-- | Read the file metadata from a Parquet file at the given path.
readMetadataFromPath :: FilePath -> IO FileMetadata
readMetadataFromPath path =
withFileBufferedOrSeekable Nothing path ReadMode $
runReaderIO parseFileMetadata
-- | Read only the file metadata from an open 'FileBufferedOrSeekable' handle.
readMetadataFromHandle :: FileBufferedOrSeekable -> IO FileMetadata
readMetadataFromHandle = runReaderIO parseFileMetadata
-- | Collect column chunks per column (transposed across all row groups).
columnChunksForAll :: FileMetadata -> [[ColumnChunk]]
columnChunksForAll =
transpose . map (unField . rg_columns) . unField . row_groups
-- | Dispatch a column's chunks to the correct decoder path.
parseColumnChunks ::
(RandomAccess m, MonadIO m) =>
Int ->
[ColumnChunk] ->
ColumnDescription ->
m Column
{-# SPECIALIZE parseColumnChunks ::
Int ->
[ColumnChunk] ->
ColumnDescription ->
ReaderIO FileBufferedOrSeekable Column
#-}
{-# SPECIALIZE parseColumnChunks ::
Int ->
[ColumnChunk] ->
ColumnDescription ->
ReaderIO ChunkBuffer Column
#-}
{-# INLINEABLE parseColumnChunks #-}
parseColumnChunks totalRows chunks description
| description.maxRepetitionLevel == 0 && description.maxDefinitionLevel == 0 =
getNonNullableColumn totalRows description chunks
| description.maxRepetitionLevel == 0 =
getNullableColumn totalRows description chunks
| otherwise =
getRepeatedColumn description chunks
-- | Decode a required (non-nullable, non-repeated) column.
{-# INLINEABLE getNonNullableColumn #-}
getNonNullableColumn ::
forall m.
(RandomAccess m, MonadIO m) =>
Int ->
ColumnDescription ->
[ColumnChunk] ->
m Column
getNonNullableColumn totalRows description chunks =
case description.colElementType of
Just (BOOLEAN _) -> unboxedGo boolDecoder
Just (INT32 _) -> unboxedGo int32Decoder
Just (INT64 _) -> unboxedGo int64Decoder
Just (INT96 _) -> go int96Decoder
Just (FLOAT _) -> unboxedGo floatDecoder
Just (DOUBLE _) -> unboxedGo doubleDecoder
Just (BYTE_ARRAY _) -> goPackedText
Just (FIXED_LEN_BYTE_ARRAY _) -> case description.typeLength of
Nothing -> error "FIXED_LEN_BYTE_ARRAY requires type_length to be set"
Just tl -> go (fixedLenByteArrayDecoder (fromIntegral tl))
Nothing -> error "Column has no Parquet type"
where
go ::
forall a.
(Columnable a) =>
PageDecoder a ->
m Column
go decoder =
foldNonNullable totalRows (foldColumnPagesM description decoder chunks)
-- Decode non-nullable UTF-8 BYTE_ARRAY straight into one 'PackedText'.
goPackedText :: m Column
goPackedText = do
builder <-
liftIO $ stToIO (newTextBuilder totalRows (textByteHint totalRows chunks))
_ <-
foldColumnDataPagesM
description
chunks
( \() (dict, enc, nPresent, valBytes, _, _) ->
liftIO (appendStringPageIO builder dict enc nPresent valBytes)
)
()
chunk <- liftIO $ stToIO (freezeTextChunk builder)
pure (mergeTextChunks [chunk])
unboxedGo ::
forall a.
(Columnable a, VU.Unbox a) =>
UnboxedPageDecoder a ->
m Column
unboxedGo decoder =
foldNonNullableUnboxed totalRows (foldColumnPagesM description decoder chunks)
-- | Initial byte capacity for a text column.
textByteHint :: Int -> [ColumnChunk] -> Int
textByteHint totalRows chunks =
max (totalRows * 8) $
sum
[ fromIntegral (unField (cmd_total_uncompressed_size meta))
| cc <- chunks
, Just meta <- [unField (cc_meta_data cc)]
]
-- | Decode an optional (nullable) column.
{-# INLINEABLE getNullableColumn #-}
getNullableColumn ::
forall m.
(RandomAccess m, MonadIO m) =>
Int ->
ColumnDescription ->
[ColumnChunk] ->
m Column
getNullableColumn totalRows description chunks =
case description.colElementType of
Just (BOOLEAN _) -> unboxedGo boolDecoder
Just (INT32 _) -> unboxedGo int32Decoder
Just (INT64 _) -> unboxedGo int64Decoder
Just (INT96 _) -> go int96Decoder
Just (FLOAT _) -> unboxedGo floatDecoder
Just (DOUBLE _) -> unboxedGo doubleDecoder
Just (BYTE_ARRAY _) -> goPackedTextNullable
Just (FIXED_LEN_BYTE_ARRAY _) -> case description.typeLength of
Nothing -> error "FIXED_LEN_BYTE_ARRAY requires type_length to be set"
Just tl -> go (fixedLenByteArrayDecoder (fromIntegral tl))
Nothing -> error "Column has no Parquet type"
where
maxDef :: Int
maxDef = fromIntegral description.maxDefinitionLevel
go ::
forall a.
(Columnable a) =>
PageDecoder a ->
m Column
go decoder =
foldNullable maxDef totalRows (foldColumnPagesM description decoder chunks)
-- Nullable UTF-8 BYTE_ARRAY: decode straight into a 'PackedText'.
goPackedTextNullable :: m Column
goPackedTextNullable = do
builder <-
liftIO $ stToIO (newTextBuilder totalRows (textByteHint totalRows chunks))
_ <-
foldColumnDataPagesM
description
chunks
( \() (dict, enc, nPresent, valBytes, defs, _) ->
liftIO
(appendNullableStringPageIO builder maxDef dict enc nPresent valBytes defs)
)
()
chunk <- liftIO $ stToIO (freezeTextChunk builder)
pure (mergeTextChunks [chunk])
unboxedGo ::
forall a.
(Columnable a, VU.Unbox a) =>
UnboxedPageDecoder a ->
m Column
unboxedGo decoder =
foldNullableUnboxed
maxDef
totalRows
(foldColumnPagesM description decoder chunks)
-- | Decode a repeated (list/nested) column.
{-# INLINEABLE getRepeatedColumn #-}
getRepeatedColumn ::
forall m.
(RandomAccess m, MonadIO m) =>
ColumnDescription ->
[ColumnChunk] ->
m Column
getRepeatedColumn description chunks =
case description.colElementType of
Just (BOOLEAN _) -> unboxedGo boolDecoder
Just (INT32 _) -> unboxedGo int32Decoder
Just (INT64 _) -> unboxedGo int64Decoder
Just (INT96 _) -> go int96Decoder
Just (FLOAT _) -> unboxedGo floatDecoder
Just (DOUBLE _) -> unboxedGo doubleDecoder
Just (BYTE_ARRAY _) -> go byteArrayDecoder
Just (FIXED_LEN_BYTE_ARRAY _) -> case description.typeLength of
Nothing -> error "FIXED_LEN_BYTE_ARRAY requires type_length to be set"
Just tl -> go (fixedLenByteArrayDecoder (fromIntegral tl))
Nothing -> error "Column has no Parquet type"
where
maxRep :: Int
maxRep = fromIntegral description.maxRepetitionLevel
maxDef :: Int
maxDef = fromIntegral description.maxDefinitionLevel
go ::
forall a.
( Columnable a
, Columnable (Maybe [Maybe a])
, Columnable (Maybe [Maybe [Maybe a]])
, Columnable (Maybe [Maybe [Maybe [Maybe a]]])
) =>
PageDecoder a ->
m Column
go decoder =
foldRepeated maxRep maxDef (foldColumnPagesM description decoder chunks)
unboxedGo ::
forall a.
( VU.Unbox a
, Columnable a
, Columnable (Maybe [Maybe a])
, Columnable (Maybe [Maybe [Maybe a]])
, Columnable (Maybe [Maybe [Maybe [Maybe a]]])
) =>
UnboxedPageDecoder a ->
m Column
unboxedGo decoder =
foldRepeatedUnboxed maxRep maxDef (foldColumnPagesM description decoder chunks)
applyRowRange :: ParquetReadOptions -> DataFrame -> DataFrame
applyRowRange opts df =
maybe df (`DS.range` df) (rowRange opts)
applySelectedColumns :: ParquetReadOptions -> DataFrame -> DataFrame
applySelectedColumns opts df =
maybe df (`DS.select` df) (selectedColumns opts)
applyPredicate :: ParquetReadOptions -> DataFrame -> DataFrame
applyPredicate opts df =
maybe df (`DS.filterWhere` df) (predicate opts)
applySafeRead :: ParquetReadOptions -> DataFrame -> DataFrame
applySafeRead opts df
| safeColumns opts = df{columns = Vector.map DI.ensureOptional (columns df)}
| otherwise = df
applyReadOptions :: ParquetReadOptions -> DataFrame -> DataFrame
applyReadOptions opts =
applySafeRead opts
. applyRowRange opts
. applySelectedColumns opts
. applyPredicate opts
-- | Apply a column's logical type annotation to its decoded values.
applyDescLogicalType :: ColumnDescription -> DI.Column -> DI.Column
applyDescLogicalType desc = applyLogicalType (colLogicalType desc)
applyLogicalType :: Maybe LogicalType -> DI.Column -> DI.Column
applyLogicalType (Just (LT_TIMESTAMP f)) col =
let ts = unField f
unit = unField ts.timestamp_unit
-- Convert at native precision (ticks per second, ps per tick).
conv = case unit of
MILLIS _ -> epochToUTCTime 1_000 1_000_000_000
MICROS _ -> epochToUTCTime 1_000_000 1_000_000
NANOS _ -> epochToUTCTime 1_000_000_000 1_000
in fromRight col $ DI.mapColumn conv col
applyLogicalType (Just (LT_DECIMAL f)) col =
let dt = unField f
scale = unField dt.decimal_scale
precision = unField dt.decimal_precision
in if precision <= 9
then
fromRight col $
DI.mapColumn (\raw -> fromIntegral @Int32 @Double raw / 10 ^ scale) col
else
if precision <= 18
then
fromRight col $
DI.mapColumn (\raw -> fromIntegral @Int64 @Double raw / 10 ^ scale) col
else col
applyLogicalType _ col = col
nativeTypeHints :: FileMetadata -> Schema
nativeTypeHints metadata =
makeSchema
[ (name, ty)
| kv <- concat (unField metadata.key_value_metadata)
, Just name <- [T.stripPrefix nativeTypeKeyPrefix (unField kv.kv_key)]
, Just value <- [unField kv.kv_value]
, Just ty <- [Map.lookup value stampedSchemaTypes]
]
stampedSchemaTypes :: Map.Map T.Text SchemaType
stampedSchemaTypes =
Map.fromList $
concat
[ entry @Int "Int"
, entry @Int32 "Int32"
, entry @Int64 "Int64"
, entry @Integer "Integer"
, entry @Float "Float"
, entry @Double "Double"
, entry @Bool "Bool"
, entry @T.Text "Text"
, entry @UTCTime "UTCTime"
]
where
entry ::
forall a.
(Columnable a, Read a, Columnable (Maybe a)) =>
T.Text ->
[(T.Text, SchemaType)]
entry name =
[ (name, schemaType @a)
, ("Maybe " <> name, schemaType @(Maybe a))
]
restoreNativeType :: Schema -> T.Text -> DI.Column -> DI.Column
restoreNativeType hints name col = case lookupHint of
Just ty
| stampedAs @Int ty -> narrow (fromIntegral @Int64 @Int)
| stampedAs @Integer ty -> narrow (fromIntegral @Int64 @Integer)
_ -> col
where
lookupHint =
case Map.lookup name (elements hints) of
Just ty -> Just ty
Nothing -> Map.lookup (leafOf name) (elements hints)
stampedAs ::
forall a. (Columnable a, Read a, Columnable (Maybe a)) => SchemaType -> Bool
stampedAs ty = ty == schemaType @a || ty == schemaType @(Maybe a)
narrow :: (Columnable a) => (Int64 -> a) -> DI.Column
narrow f = fromRight col (DI.mapColumn f col)
-- | Convert an epoch timestamp with the given tick rate to 'UTCTime'.
epochToUTCTime :: Int64 -> Integer -> Int64 -> UTCTime
epochToUTCTime ticksPerSecond psPerTick v =
let (s, subTicks) = v `divMod` ticksPerSecond
(days, sInDay) = s `divMod` 86_400
ps = fromIntegral sInDay * 1_000_000_000_000 + fromIntegral subTicks * psPerTick
in UTCTime
(ModifiedJulianDay (40_587 + fromIntegral days))
(picosecondsToDiffTime ps)