wireform-parquet-0.1.0.0: src/Parquet/HighLevel.hs
{-# LANGUAGE BangPatterns #-}
{- | High-level Parquet API.
95% of callers should reach for this module. It hides the
@ColumnAux@ parallel-array machinery, the encrypted-footer
variant, and the per-row-group compression knobs of
"Parquet.Write" behind a single record-of-options:
@
-- Encode
let bytes = 'encodeParquet' 'defaultWriteOptions'
schema rowGroups
-- Read
case 'decodeParquet' bytes of
Right (schema', rowGroups') -> ...
Left err -> ...
@
@rowGroups@ is a @[V.Vector ColumnData]@ — one entry per row
group, each entry one 'ColumnData' per /leaf/ schema column in
the same order @schema@ enumerates them.
The 'WriteOptions' record consolidates every Parquet writer
knob in one place: compression, page version, bloom filters,
page-index emission, per-column encryption, and footer
encryption (PARE mode). Defaults are the modern-Parquet
recommended settings (Snappy, V2 pages, no encryption).
For lower-level control (per-column compression overrides,
bespoke 'ColumnAux' values, custom row-group dictionaries) drop
down to "Parquet.Write".
-}
module Parquet.HighLevel (
-- * Encoding
encodeParquet,
encodeParquetIO,
encodeParquetMixed,
encodeParquetMixedIO,
encodeParquetNested,
encodeParquetNestedWith,
encodeParquetNestedIO,
FooterMode (..),
WriteOptions (..),
defaultWriteOptions,
ParquetColumn (..),
OptionalColumn (..),
-- * Decoding
decodeParquet,
ReadOptions (..),
defaultReadOptions,
FooterDecryption (..),
DecryptionConfig (..),
defaultDecryptionConfig,
ColumnKeyMetadata (..),
encryptedColumnKeyMetadata,
ParquetFile (..),
-- * Re-exports for convenience
ColumnData (..),
ColumnEncryption (..),
FooterEncryption (..),
Compression (..),
PageVersion (..),
SchemaElement (..),
NestedSchema (..),
NestedRow (..),
LeafType (..),
LeafValue (..),
) where
import Crypto.Random.Types qualified as RNG
import Data.ByteString (ByteString)
import Data.ByteString qualified as BS
import Data.ByteString.Lazy qualified as BL
import Data.Int (Int32, Int64)
import Data.Text (Text)
import Data.Text.Encoding qualified as TE
import Data.Vector qualified as V
import Data.Vector.Primitive qualified as VP
import GHC.Float (castDoubleToWord64, castFloatToWord32)
import Parquet.Arrow qualified as Arrow
import Parquet.BloomFilter qualified as Bloom
import Parquet.Compress (validateCompression)
import Parquet.Footer qualified as Footer
import Parquet.Decryption (ColumnKeyMetadata (..), DecryptionConfig (..), defaultDecryptionConfig)
import Parquet.Nested (
LeafType (..),
LeafValue (..),
NestedRow (..),
NestedSchema (..),
buildNestedFile,
buildNestedFileWithAux,
buildNestedFileWithBloomFilters,
buildNestedFileWithBloomFiltersUsing,
nestedSchemaToFlatSchema,
)
import Parquet.Read (
FooterDecryption (..),
ParquetFile (..),
encryptedColumnKeyMetadata,
loadParquetFile,
loadParquetFileEncrypted,
loadParquetFileWithDecryption,
)
import Parquet.Types (
Compression (..),
FileMetadata,
SchemaElement (..),
)
import Parquet.Write.Encrypted (FooterMode (..))
import Parquet.Write.Encrypted qualified as Encrypted
import Parquet.Write (
ColumnAux (..),
ColumnData (..),
ColumnEncryption (..),
FooterEncryption (..),
OptionalColumn (..),
PageVersion (..),
ParquetColumn (..),
buildParquetFileMixedWithAux,
buildParquetFileMixedWithAuxUsing,
buildParquetFileWithIndex,
buildParquetFileWithIndexEncryptedFooter,
buildParquetFileWithIndexIO,
emptyColumnAux,
optionalColumnPresentValues,
)
import Wireform.Builder qualified as B
-- ============================================================
-- Options
-- ============================================================
{- | Parquet writer configuration. Construct one with
'defaultWriteOptions' and override the fields you care about:
@
let opts = 'defaultWriteOptions'
{ writeCompression = 'ZSTD'
, writeBloomFilters = ["sku"]
}
bytes = 'encodeParquet' opts schema rowGroups
@
-}
data WriteOptions = WriteOptions
{ writeCompression :: !Compression
{- ^ Compression codec applied to every column's data pages.
Default: 'Snappy'. Use 'Uncompressed' to skip.
-}
, writePageVersion :: !PageVersion
{- ^ Data page version. 'PageV2' is recommended for modern
writers; 'PageV1' interoperates with older Parquet readers.
Default: 'PageV2'.
-}
, writeBloomFilters :: ![Text]
{- ^ Column paths (top-level field names) that should carry a
split-block bloom filter. Empty list → no bloom filters.
Filter parameters use parquet-cpp's defaults
(~3% FPP at 1024 distinct values per row group).
-}
, writePageIndex :: !Bool
{- ^ Emit per-column 'OffsetIndex' / 'ColumnIndex' regions in
the trailing page-index area. Default: 'True' — page
indexes substantially improve scan performance for filter
pushdown and most ecosystem readers (pyarrow, parquet-mr,
DuckDB, Trino) take advantage of them when present.
-}
, writeColumnEncryption :: !(V.Vector (Maybe ColumnEncryption))
{- ^ Per-leaf-column encryption configuration. The vector
length must match the number of leaf columns; 'Nothing'
entries leave the column in plaintext. Default:
'V.empty' (no per-column encryption — interpreted as
"all columns plaintext").
-}
, writeFooterMode :: !FooterMode
-- ^ Encrypted footer (default), or an authenticated plaintext footer.
, writeFooterEncryption :: !(Maybe FooterEncryption)
{- ^ Footer encryption/signing key and file AAD context. 'writeFooterMode'
selects an encrypted footer (PARE) or a signed plaintext footer (PAR1).
'Nothing' means an ordinary unencrypted file and requires plaintext columns.
-}
}
deriving (Show, Eq)
{- | Sensible modern-Parquet defaults: Snappy compression, V2
pages, page indexes on, no encryption, no bloom filters.
-}
defaultWriteOptions :: WriteOptions
defaultWriteOptions =
WriteOptions
{ writeCompression = Snappy
, writePageVersion = PageV2
, writeBloomFilters = []
, writePageIndex = True
, writeColumnEncryption = V.empty
, writeFooterMode = EncryptedFooter
, writeFooterEncryption = Nothing
}
-- ============================================================
-- Encoding
-- ============================================================
{- | Serialise a Parquet file from a schema + row groups, applying
the supplied options.
The schema is a flat 'V.Vector' of 'SchemaElement' (one entry
per node in the parquet schema tree, including the synthetic
root). Row groups are a list of column-major collections — one
'V.Vector' 'ColumnData' per row group, each containing exactly
one entry per leaf column in declaration order.
-}
encodeParquet
:: WriteOptions
-> V.Vector SchemaElement
-> [V.Vector ColumnData]
-> ByteString
encodeParquet opts _ _
| encryptionRequested opts =
error "Parquet.HighLevel: encryption requires encodeParquetIO"
encodeParquet opts schema rgs =
let !rowGroups = V.fromList rgs
!auxes = V.map (mkAuxes opts schema) rowGroups
in case validateCompression (writeCompression opts) >> Right (writeFooterEncryption opts) of
Left err -> error err
Right Nothing -> buildParquetFileWithIndex schema rowGroups auxes
Right (Just _) -> error "Parquet.HighLevel: encryption requires encodeParquetIO"
{- | Encoding with random encryption nonces. Invalid encryption keys return
'Left', with no plaintext file produced. Unencrypted callers can continue
to use 'encodeParquet'.
-}
encodeParquetIO
:: RNG.MonadRandom m
=> WriteOptions
-> V.Vector SchemaElement
-> [V.Vector ColumnData]
-> m (Either String ByteString)
encodeParquetIO opts schema rgs =
case validateCompression (writeCompression opts) >> validateWriteEncryption opts schema of
Left err -> pure (Left err)
Right () ->
let groups = V.fromList rgs
in case writeFooterEncryption opts of
Nothing -> buildParquetFileWithIndexIO schema groups (V.map (mkAuxes opts schema) groups)
Just footer
| writeFooterMode opts == EncryptedFooter
, V.all (maybe True ((== feKey footer) . ceKey)) (writeColumnEncryption opts) ->
buildParquetFileWithIndexEncryptedFooter footer schema groups (V.map (mkAuxes opts schema) groups)
Just _ -> do
let plainOptions = withoutEncryption opts
auxes = V.map (mkAuxes plainOptions schema) groups
plaintext <- buildParquetFileWithIndexIO schema groups auxes
finishEncrypted opts plaintext
withoutEncryption :: WriteOptions -> WriteOptions
withoutEncryption opts = opts {writeColumnEncryption = V.empty, writeFooterEncryption = Nothing, writeFooterMode = EncryptedFooter}
finishEncrypted :: RNG.MonadRandom m => WriteOptions -> Either String ByteString -> m (Either String ByteString)
finishEncrypted _ (Left err) = pure (Left err)
finishEncrypted opts (Right plaintext) = case writeFooterEncryption opts of
Nothing -> pure (Right plaintext)
Just footer -> Encrypted.encryptWrittenFile (writeFooterMode opts) footer (writeColumnEncryption opts) plaintext
finishWriterOutput :: RNG.MonadRandom m => FooterEncryption -> WriteOptions -> Either String Encrypted.WriterOutput -> m (Either String ByteString)
finishWriterOutput _ _ (Left err) = pure (Left err)
finishWriterOutput footer opts (Right output) =
Encrypted.encryptWriterOutput (writeFooterMode opts) footer (writeColumnEncryption opts) output
{- | Build the leaf-name -> column-index lookup for a flat
schema. The synthetic root is at index 0; leaves start at 1
in the schema vector but are addressed 0-indexed in the
per-row-group columns. Used by the bloom-filter populator to
map the @writeBloomFilters@ name list to column positions.
-}
leafColumnIndex :: V.Vector SchemaElement -> Text -> Maybe Int
leafColumnIndex schema name =
let leaves = V.filter (maybe False (const True) . seType) schema
in V.findIndex ((== name) . seName) leaves
{- | Serialise a Parquet file from a schema + row groups where
each column may be either required ('PCRequired') or nullable
('PCOptional'). Honors compression, page version, bloom filters and page
indexes. Unsupported codecs and invalid layouts raise a descriptive error;
they never silently fall back to another codec. Use 'encodeParquetMixedIO'
for encryption with fresh random nonces.
-}
encodeParquetMixed
:: WriteOptions
-> V.Vector SchemaElement
-> [V.Vector ParquetColumn]
-> ByteString
encodeParquetMixed opts _ _
| encryptionRequested opts =
error "Parquet.HighLevel: encryption is not supported by the mixed writer"
encodeParquetMixed opts schema rgs =
either error id $
validateCompression (writeCompression opts) >>
buildParquetFileMixedWithAuxUsing plainMixedOutput schema (V.fromList rgs) (V.map (mkAuxes opts schema))
plainMixedOutput :: FileMetadata -> [ByteString] -> ByteString
plainMixedOutput metadata parts = BS.concat (Footer.parquetMagic : parts ++ [Footer.writeFooter metadata])
-- | Nullable/required columns with fresh random encryption nonces.
encodeParquetMixedIO
:: RNG.MonadRandom m
=> WriteOptions
-> V.Vector SchemaElement
-> [V.Vector ParquetColumn]
-> m (Either String ByteString)
encodeParquetMixedIO opts schema rgs =
case validateCompression (writeCompression opts) >> validateWriteEncryption opts schema of
Left err -> pure (Left err)
Right () ->
let plainOptions = withoutEncryption opts
groups = V.fromList rgs
makeAuxes = V.map (mkAuxes plainOptions schema)
in case writeFooterEncryption opts of
Nothing -> pure (buildParquetFileMixedWithAuxUsing plainMixedOutput schema groups makeAuxes)
Just footer ->
finishWriterOutput footer opts $
buildParquetFileMixedWithAuxUsing (\metadata parts -> Encrypted.WriterOutput metadata (BS.concat (Footer.parquetMagic : parts))) schema groups makeAuxes
encryptionRequested :: WriteOptions -> Bool
encryptionRequested opts =
writeFooterMode opts == SignedPlaintextFooter
|| maybe False (const True) (writeFooterEncryption opts)
|| V.any (maybe False (const True)) (writeColumnEncryption opts)
{- | Compute a 'ColumnAux' vector per row group from the
write-time options.
Compression + page version are applied uniformly to every
column. Bloom filters are built per-column for the leaves
whose names appear in 'writeBloomFilters' — the writer hashes
each value's PLAIN payload into a fresh 'Sbbf' sized for the
column's row count so a downstream reader can probe membership
via 'Parquet.Predicate.evalBloomChunk' without false
negatives.
-}
mkAuxes
:: WriteOptions
-> V.Vector SchemaElement
-> V.Vector ColumnData
-> V.Vector ColumnAux
mkAuxes opts schema cols =
let !bloomNames = writeBloomFilters opts
bloomIdxs =
foldr (\name indices -> maybe indices (: indices) (leafColumnIndex schema name)) [] bloomNames
bloomSet = bloomIdxs
in V.imap (\i col -> mkOne i col bloomSet) cols
where
mkOne :: Int -> ColumnData -> [Int] -> ColumnAux
mkOne i col blooms =
emptyColumnAux
{ caCodec = writeCompression opts
, caEncryption = maybe Nothing id (writeColumnEncryption opts V.!? i)
, caGeneratePageIndex = writePageIndex opts
, caPageVersion = writePageVersion opts
, caBloomFilter =
if i `elem` blooms
then Just $! buildBloomFilterFor col
else Nothing
}
-- Check the requested configuration even for an empty dataset. Building aux
-- metadata from zero row groups cannot detect a truncated key vector or a
-- missing encrypted footer on its own.
validateWriteEncryption :: WriteOptions -> V.Vector SchemaElement -> Either String ()
validateWriteEncryption opts schema
| not (V.null columns) && V.length columns /= V.length leaves = Left "Parquet.HighLevel: column encryption vector must match the leaf-column count"
| writeFooterMode opts == SignedPlaintextFooter && writeFooterEncryption opts == Nothing = Left "Parquet.HighLevel: signed plaintext footer requires a signing key"
| otherwise = do
case writeFooterEncryption opts of
Nothing -> Right ()
Just footer
| BS.length (feKey footer) `notElem` [16,24,32] -> Left "Parquet.HighLevel: AES key must contain 16, 24, or 32 bytes"
| BS.null (feFileId footer) -> Left "Parquet.HighLevel: footer requires a fresh random file identifier"
| otherwise -> Right ()
V.imapM_ check columns
where
columns = writeColumnEncryption opts
leaves = V.filter (maybe False (const True) . seType) schema
algorithm =
V.foldl'
( \found entry -> case found of
Just _ -> found
Nothing -> ceAlgorithm <$> entry
)
Nothing
columns
check _ Nothing = Right ()
check index (Just column)
| index > 32767 = Left "Parquet.HighLevel: encrypted column ordinal exceeds signed 16-bit range"
| BS.length (ceKey column) `notElem` [16, 24, 32] = Left "Parquet.HighLevel: AES key must contain 16, 24, or 32 bytes"
| otherwise = case writeFooterEncryption opts of
Nothing -> Left "Parquet.HighLevel: encrypted columns require an encrypted footer"
Just footer
| ceFileId column /= feFileId footer || ceAadPrefix column /= feAadPrefix footer -> Left "Parquet.HighLevel: column and footer AAD contexts must match"
| Just (ceAlgorithm column) /= algorithm -> Left "Parquet.HighLevel: encrypted columns must share one file-level algorithm"
| otherwise -> Right ()
{- | Build a split-block bloom filter populated from a column's
values. Sizes the filter via 'Bloom.optimalNumBytes' for the
column's row count at a 1% false-positive rate (parquet-cpp's
default), then inserts each value's PLAIN-encoded payload —
matching what 'Parquet.Predicate.encodePlain' probes with on
the read side.
-}
buildBloomFilterFor :: ColumnData -> Bloom.Sbbf
buildBloomFilterFor col =
let !ndv = max 1 (columnDistinctEstimate col)
!nBytes = Bloom.optimalNumBytes ndv 0.01
!empty0 = Bloom.newSbbf nBytes
in case col of
ColInt32 v ->
VP.foldl' (\acc x -> Bloom.sbbfInsert (i32LE x) acc) empty0 v
ColInt64 v ->
VP.foldl' (\acc x -> Bloom.sbbfInsert (i64LE x) acc) empty0 v
ColFloat v ->
VP.foldl' (\acc x -> Bloom.sbbfInsert (f32LE x) acc) empty0 v
ColDouble v ->
VP.foldl' (\acc x -> Bloom.sbbfInsert (f64LE x) acc) empty0 v
ColBool v ->
V.foldl' (\acc x -> Bloom.sbbfInsert (boolPayload x) acc) empty0 v
ColByteArray v ->
V.foldl' (\acc x -> Bloom.sbbfInsert x acc) empty0 v
{- | Cheap distinct-value upper bound: row count. Real
distinct-counting would need a second pass; sizing for the
worst case (all-distinct) keeps the filter slightly oversize
but never underfilled.
-}
columnDistinctEstimate :: ColumnData -> Int
columnDistinctEstimate = \case
ColInt32 v -> VP.length v
ColInt64 v -> VP.length v
ColFloat v -> VP.length v
ColDouble v -> VP.length v
ColBool v -> V.length v
ColByteArray v -> V.length v
{- | PLAIN encodings used for bloom-filter inserts. These must
match 'Parquet.Predicate.encodePlain' byte-for-byte so a
@PEq@ predicate hashes to the same key.
-}
i32LE :: Int32 -> ByteString
i32LE = BL.toStrict . B.toLazyByteString . B.int32LE
i64LE :: Int64 -> ByteString
i64LE = BL.toStrict . B.toLazyByteString . B.int64LE
f32LE :: Float -> ByteString
f32LE f = BL.toStrict (B.toLazyByteString (B.word32LE (castFloatToWord32 f)))
f64LE :: Double -> ByteString
f64LE d = BL.toStrict (B.toLazyByteString (B.word64LE (castDoubleToWord64 d)))
boolPayload :: Bool -> ByteString
boolPayload True = TE.encodeUtf8 "\x01"
boolPayload False = TE.encodeUtf8 "\x00"
-- | Nested records with random module nonces, per-leaf keys and either
-- encrypted or signed plaintext footer. Pure nested writing remains available
-- through 'encodeParquetNestedWith' when encryption is not requested.
encodeParquetNestedIO
:: RNG.MonadRandom m
=> WriteOptions
-> V.Vector (Text, NestedSchema)
-> V.Vector (V.Vector NestedRow)
-> m (Either String ByteString)
encodeParquetNestedIO opts columns rows =
case validateCompression (writeCompression opts) >> validateWriteEncryption opts schema of
Left err -> pure (Left err)
Right () -> case writeFooterEncryption opts of
Nothing -> pure (encodeParquetNestedWith (withoutEncryption opts) columns rows)
Just footer ->
let leaves = V.foldl' (\count field -> count + maybe 0 (const 1) (seType field)) 0 schema
aux = emptyColumnAux {caCodec = writeCompression opts, caPageVersion = writePageVersion opts, caGeneratePageIndex = writePageIndex opts}
in finishWriterOutput footer opts $
buildNestedFileWithBloomFiltersUsing Encrypted.nestedWriterOutput (writeBloomFilters opts) (V.replicate leaves aux) columns rows
where
schema = nestedSchemaToFlatSchema columns
-- ============================================================
-- Decoding
-- ============================================================
{- | Serialise a Parquet file carrying /nested/ (struct / list /
map / variant) columns. Delegates to 'Parquet.Nested.buildNestedFile'
after the Dremel shredder in "Parquet.Nested".
@columns@ is a vector of @(column-name, schema)@ pairs;
@rowsPerColumn@ is a parallel vector where the i-th entry is
the row-major list of 'NestedRow' values for column @i@. Every
column must carry the same number of rows.
This entry point retains the original uncompressed @DATA_PAGE_V2@
layout. Use 'encodeParquetNestedWith' to select compression, page version
and indexes with 'WriteOptions', or 'encodeParquetNestedIO' for encryption.
-}
encodeParquetNested
:: V.Vector (Text, NestedSchema)
-> V.Vector (V.Vector NestedRow)
-> Either String ByteString
encodeParquetNested = buildNestedFile
{- | Nested writing with compression, page version and generated indexes.
The existing 'encodeParquetNested' retains its uncompressed V2 defaults.
Names in 'writeBloomFilters' select top-level fields and populate every
physical leaf below them. Unknown names return 'Left'. Encryption uses
'encodeParquetNestedIO'. Pre-built per-leaf filters and explicit indexes are available
through 'buildNestedFileWithAux'.
-}
encodeParquetNestedWith
:: WriteOptions
-> V.Vector (Text, NestedSchema)
-> V.Vector (V.Vector NestedRow)
-> Either String ByteString
encodeParquetNestedWith opts columns rows
| encryptionRequested opts = Left "Parquet.HighLevel: nested encryption requires an IO writer"
| otherwise =
let schema = nestedSchemaToFlatSchema columns
leaves = V.length (V.filter (maybe False (const True) . seType) schema)
aux = emptyColumnAux {caCodec = writeCompression opts, caPageVersion = writePageVersion opts, caGeneratePageIndex = writePageIndex opts}
in buildNestedFileWithBloomFilters (writeBloomFilters opts) (V.replicate leaves aux) columns rows
-- ============================================================
-- Decoding
-- ============================================================
{- | Parquet reader configuration. Construct one with
'defaultReadOptions' and override the fields you care about.
@
let opts = 'defaultReadOptions'
{ readFooterDecryption = Just ('FooterDecryption' key fileId prefix) }
'decodeParquet' opts bytes
@
-}
data ReadOptions = ReadOptions
{ readFooterDecryption :: !(Maybe FooterDecryption)
{- ^ When 'Just', authenticate an encrypted footer (PARE) or a signed
plaintext footer (PAR1) with the supplied key, file identifier and AAD
prefix. Ordinary unsigned files are also accepted. When 'Nothing'
(the default), a plaintext footer is parsed without signature verification.
-}
, readDecryptionConfig :: !(Maybe DecryptionConfig)
-- ^ Full file/column key routing. Do not also set 'readFooterDecryption'.
}
deriving (Show, Eq)
-- | Plaintext-footer defaults (matches the common case).
defaultReadOptions :: ReadOptions
defaultReadOptions =
ReadOptions
{ readFooterDecryption = Nothing
, readDecryptionConfig = Nothing
}
{- | Parse a Parquet file's footer. By default expects a
plaintext PAR1 footer without signature verification; pass
'readFooterDecryption' in 'ReadOptions' to decrypt a PARE footer or
authenticate a signed plaintext footer and read footer-key columns. Use
'readDecryptionConfig' for column-specific keys and opaque key-metadata routing.
@
-- plaintext
'decodeParquet' 'defaultReadOptions' bytes
-- encrypted footer
'decodeParquet'
'defaultReadOptions' { 'readFooterDecryption' = Just fd }
bytes
@
-}
decodeParquet :: ReadOptions -> ByteString -> Either String ParquetFile
decodeParquet opts bytes = do
file <- case (readDecryptionConfig opts, readFooterDecryption opts) of
(Nothing, Nothing) -> loadParquetFile bytes
(Nothing, Just fd) -> loadParquetFileEncrypted fd bytes
(Just config, Nothing) -> loadParquetFileWithDecryption config bytes
(Just _, Just _) -> Left "Parquet.HighLevel: set only one decryption configuration"
_ <- Arrow.parquetFileArrowSchemaChecked file
Right file