packages feed

dataframe-parquet 1.5.0.1 → 1.5.1.0

raw patch · 20 files changed

+2116/−10 lines, 20 filesdep +HUnitdep +criteriondep +dataframe-parquetdep ~directorynew-component:exe:dataframe-parquet-10gb-stressbinary-addedPVP: major bump suggested

API removals or changes: PVP suggests a major version bump

Dependencies added: HUnit, criterion, dataframe-parquet, deepseq, primitive, temporary

Dependency ranges changed: directory

API changes (from Hackage documentation)

- DataFrame.IO.Parquet: epochToUTCTime :: Int64 -> Integer -> Int64 -> UTCTime
- DataFrame.IO.Utils.RandomAccess: ReaderIO :: (r -> IO a) -> ReaderIO r a
- DataFrame.IO.Utils.RandomAccess: [runReaderIO] :: ReaderIO r a -> r -> IO a
- DataFrame.IO.Utils.RandomAccess: newtype ReaderIO r a
+ DataFrame.IO.Parquet: ColumnDescription :: !Maybe ThriftType -> !Int32 -> !Int32 -> !Maybe LogicalType -> !Maybe ConvertedType -> !Maybe Int32 -> ColumnDescription
+ DataFrame.IO.Parquet: InMemory :: WriterStrategy
+ DataFrame.IO.Parquet: ParquetWriteOptions :: !Int -> !Int -> !Int -> !Int -> !CompressionCodec -> !WriterStrategy -> !Maybe Int -> ParquetWriteOptions
+ DataFrame.IO.Parquet: TwoPass :: WriterStrategy
+ DataFrame.IO.Parquet: [batchRows] :: ParquetWriteOptions -> !Int
+ DataFrame.IO.Parquet: [colConvertedType] :: ColumnDescription -> !Maybe ConvertedType
+ DataFrame.IO.Parquet: [colElementType] :: ColumnDescription -> !Maybe ThriftType
+ DataFrame.IO.Parquet: [colLogicalType] :: ColumnDescription -> !Maybe LogicalType
+ DataFrame.IO.Parquet: [compressionCodec] :: ParquetWriteOptions -> !CompressionCodec
+ DataFrame.IO.Parquet: [maxDefinitionLevel] :: ColumnDescription -> !Int32
+ DataFrame.IO.Parquet: [maxRepetitionLevel] :: ColumnDescription -> !Int32
+ DataFrame.IO.Parquet: [maxRowsPerFile] :: ParquetWriteOptions -> !Maybe Int
+ DataFrame.IO.Parquet: [pageSize] :: ParquetWriteOptions -> !Int
+ DataFrame.IO.Parquet: [rowGroupSize] :: ParquetWriteOptions -> !Int
+ DataFrame.IO.Parquet: [strategy] :: ParquetWriteOptions -> !WriterStrategy
+ DataFrame.IO.Parquet: [subBatchRows] :: ParquetWriteOptions -> !Int
+ DataFrame.IO.Parquet: [typeLength] :: ColumnDescription -> !Maybe Int32
+ DataFrame.IO.Parquet: class Monad m => RandomAccess (m :: Type -> Type)
+ DataFrame.IO.Parquet: data ColumnDescription
+ DataFrame.IO.Parquet: data FileBufferedOrSeekable
+ DataFrame.IO.Parquet: data ParquetWriteOptions
+ DataFrame.IO.Parquet: data ReaderIO r a
+ DataFrame.IO.Parquet: data WriterStrategy
+ DataFrame.IO.Parquet: defaultParquetWriteOptions :: ParquetWriteOptions
+ DataFrame.IO.Parquet: generateColumnDescriptions :: [SchemaElement] -> [ColumnDescription]
+ DataFrame.IO.Parquet: getColumnNames :: [SchemaElement] -> [Text]
+ DataFrame.IO.Parquet: nativeTypeHints :: FileMetadata -> Schema
+ DataFrame.IO.Parquet: readBytes :: RandomAccess m => Range -> m ByteString
+ DataFrame.IO.Parquet: readRanges :: RandomAccess m => [Range] -> m [ByteString]
+ DataFrame.IO.Parquet: readSuffix :: RandomAccess m => Int -> m ByteString
+ DataFrame.IO.Parquet: restoreNativeType :: Schema -> Text -> Column -> Column
+ DataFrame.IO.Parquet: type ForceNonSeekable = Maybe Bool
+ DataFrame.IO.Parquet: withFileBufferedOrSeekable :: ForceNonSeekable -> FilePath -> IOMode -> (FileBufferedOrSeekable -> IO a) -> IO a
+ DataFrame.IO.Parquet: writeParquet :: FilePath -> DataFrame -> IO ()
+ DataFrame.IO.Parquet: writeParquetWithOptions :: ParquetWriteOptions -> FilePath -> DataFrame -> IO ()
+ DataFrame.IO.Parquet.Writer: InMemory :: WriterStrategy
+ DataFrame.IO.Parquet.Writer: ParquetWriteOptions :: !Int -> !Int -> !Int -> !Int -> !CompressionCodec -> !WriterStrategy -> !Maybe Int -> ParquetWriteOptions
+ DataFrame.IO.Parquet.Writer: TwoPass :: WriterStrategy
+ DataFrame.IO.Parquet.Writer: [batchRows] :: ParquetWriteOptions -> !Int
+ DataFrame.IO.Parquet.Writer: [compressionCodec] :: ParquetWriteOptions -> !CompressionCodec
+ DataFrame.IO.Parquet.Writer: [maxRowsPerFile] :: ParquetWriteOptions -> !Maybe Int
+ DataFrame.IO.Parquet.Writer: [pageSize] :: ParquetWriteOptions -> !Int
+ DataFrame.IO.Parquet.Writer: [rowGroupSize] :: ParquetWriteOptions -> !Int
+ DataFrame.IO.Parquet.Writer: [strategy] :: ParquetWriteOptions -> !WriterStrategy
+ DataFrame.IO.Parquet.Writer: [subBatchRows] :: ParquetWriteOptions -> !Int
+ DataFrame.IO.Parquet.Writer: data ParquetWriteOptions
+ DataFrame.IO.Parquet.Writer: data WriterStrategy
+ DataFrame.IO.Parquet.Writer: defaultParquetWriteOptions :: ParquetWriteOptions
+ DataFrame.IO.Parquet.Writer: nativeTypeKeyPrefix :: Text
+ DataFrame.IO.Parquet.Writer: nativeTypeKeyValues :: [Text] -> DataFrame -> [(Text, Text)]
+ DataFrame.IO.Parquet.Writer: writeParquet :: FilePath -> DataFrame -> IO ()
+ DataFrame.IO.Parquet.Writer: writeParquetWithOptions :: ParquetWriteOptions -> FilePath -> DataFrame -> IO ()
+ DataFrame.IO.Parquet.Writer.DefLevels: DefLevels :: !MemoryBuffer -> !IORef Int -> !IORef Int -> DefLevels
+ DataFrame.IO.Parquet.Writer.DefLevels: [dlBuf] :: DefLevels -> !MemoryBuffer
+ DataFrame.IO.Parquet.Writer.DefLevels: [dlCount] :: DefLevels -> !IORef Int
+ DataFrame.IO.Parquet.Writer.DefLevels: [dlValue] :: DefLevels -> !IORef Int
+ DataFrame.IO.Parquet.Writer.DefLevels: data DefLevels
+ DataFrame.IO.Parquet.Writer.DefLevels: flushDef :: DefLevels -> IO ()
+ DataFrame.IO.Parquet.Writer.DefLevels: newDefLevels :: IO DefLevels
+ DataFrame.IO.Parquet.Writer.DefLevels: pushDef :: DefLevels -> Int -> IO ()
+ DataFrame.IO.Parquet.Writer.Encoder: Encoder :: !ThriftType -> !Maybe ConvertedType -> !Maybe LogicalType -> !MemoryBuffer -> Int -> Int -> IO (Int, Bool) -> !MemoryBuffer -> Int -> IO Int -> Encoder
+ DataFrame.IO.Parquet.Writer.Encoder: [convertedType] :: Encoder -> !Maybe ConvertedType
+ DataFrame.IO.Parquet.Writer.Encoder: [encType] :: Encoder -> !ThriftType
+ DataFrame.IO.Parquet.Writer.Encoder: [encodeValue] :: Encoder -> !MemoryBuffer -> Int -> Int -> IO (Int, Bool)
+ DataFrame.IO.Parquet.Writer.Encoder: [finishValues] :: Encoder -> !MemoryBuffer -> Int -> IO Int
+ DataFrame.IO.Parquet.Writer.Encoder: [logicalType] :: Encoder -> !Maybe LogicalType
+ DataFrame.IO.Parquet.Writer.Encoder: buildEncoder :: Column -> IO Encoder
+ DataFrame.IO.Parquet.Writer.Encoder: data Encoder
+ DataFrame.IO.Parquet.Writer.Metadata: magic :: ByteString
+ DataFrame.IO.Parquet.Writer.Metadata: mkColumnChunk :: CompressionCodec -> ThriftType -> Text -> Int64 -> Int -> Int64 -> Int -> ColumnChunk
+ DataFrame.IO.Parquet.Writer.Metadata: mkDataPageHeader :: Int -> Int -> Int -> PageHeader
+ DataFrame.IO.Parquet.Writer.Metadata: mkRowGroup :: [ColumnChunk] -> Int64 -> Int64 -> Int -> RowGroup
+ DataFrame.IO.Parquet.Writer.Metadata: mkSchemaElem :: Text -> ThriftType -> Bool -> Maybe ConvertedType -> Maybe LogicalType -> SchemaElement
+ DataFrame.IO.Parquet.Writer.Metadata: rootSchemaElement :: Int -> SchemaElement
+ DataFrame.IO.Parquet.Writer.Metadata: writeFooter :: WritableBinaryHandle -> [SchemaElement] -> Int -> [RowGroup] -> [(Text, Text)] -> IO ()
+ DataFrame.IO.Parquet.Writer.Options: InMemory :: WriterStrategy
+ DataFrame.IO.Parquet.Writer.Options: ParquetWriteOptions :: !Int -> !Int -> !Int -> !Int -> !CompressionCodec -> !WriterStrategy -> !Maybe Int -> ParquetWriteOptions
+ DataFrame.IO.Parquet.Writer.Options: TwoPass :: WriterStrategy
+ DataFrame.IO.Parquet.Writer.Options: [batchRows] :: ParquetWriteOptions -> !Int
+ DataFrame.IO.Parquet.Writer.Options: [compressionCodec] :: ParquetWriteOptions -> !CompressionCodec
+ DataFrame.IO.Parquet.Writer.Options: [maxRowsPerFile] :: ParquetWriteOptions -> !Maybe Int
+ DataFrame.IO.Parquet.Writer.Options: [pageSize] :: ParquetWriteOptions -> !Int
+ DataFrame.IO.Parquet.Writer.Options: [rowGroupSize] :: ParquetWriteOptions -> !Int
+ DataFrame.IO.Parquet.Writer.Options: [strategy] :: ParquetWriteOptions -> !WriterStrategy
+ DataFrame.IO.Parquet.Writer.Options: [subBatchRows] :: ParquetWriteOptions -> !Int
+ DataFrame.IO.Parquet.Writer.Options: data ParquetWriteOptions
+ DataFrame.IO.Parquet.Writer.Options: data WriterStrategy
+ DataFrame.IO.Parquet.Writer.Options: defaultParquetWriteOptions :: ParquetWriteOptions
+ DataFrame.IO.Parquet.Writer.Options: instance GHC.Classes.Eq DataFrame.IO.Parquet.Writer.Options.ParquetWriteOptions
+ DataFrame.IO.Parquet.Writer.Options: instance GHC.Classes.Eq DataFrame.IO.Parquet.Writer.Options.WriterStrategy
+ DataFrame.IO.Parquet.Writer.Options: instance GHC.Show.Show DataFrame.IO.Parquet.Writer.Options.ParquetWriteOptions
+ DataFrame.IO.Parquet.Writer.Options: instance GHC.Show.Show DataFrame.IO.Parquet.Writer.Options.WriterStrategy
+ DataFrame.IO.Utils.RandomAccess: MemoryBuffer :: !IORef (MutableByteArray RealWorld) -> !IORef Int -> MemoryBuffer
+ DataFrame.IO.Utils.RandomAccess: [arrayRef] :: MemoryBuffer -> !IORef (MutableByteArray RealWorld)
+ DataFrame.IO.Utils.RandomAccess: [positionRef] :: MemoryBuffer -> !IORef Int
+ DataFrame.IO.Utils.RandomAccess: appendTextArraySlice :: MemoryBuffer -> Array -> Int -> Int -> IO ()
+ DataFrame.IO.Utils.RandomAccess: atomicallyWriteFile :: FilePath -> (FilePath -> IO a) -> IO a
+ DataFrame.IO.Utils.RandomAccess: bufferResidency :: MemoryBuffer -> IO Int
+ DataFrame.IO.Utils.RandomAccess: bufferToByteString :: MemoryBuffer -> IO ByteString
+ DataFrame.IO.Utils.RandomAccess: data MemoryBuffer
+ DataFrame.IO.Utils.RandomAccess: data ReaderIO r a
+ DataFrame.IO.Utils.RandomAccess: data WritableBinaryHandle
+ DataFrame.IO.Utils.RandomAccess: ensureCapacity :: MemoryBuffer -> Int -> IO (MutableByteArray RealWorld)
+ DataFrame.IO.Utils.RandomAccess: flushBufferToBuffer :: MemoryBuffer -> MemoryBuffer -> IO ()
+ DataFrame.IO.Utils.RandomAccess: flushBufferToFile :: WritableBinaryHandle -> MemoryBuffer -> IO ()
+ DataFrame.IO.Utils.RandomAccess: mallocBuffer :: Int -> IO MemoryBuffer
+ DataFrame.IO.Utils.RandomAccess: openWritableBinaryFile :: FilePath -> IO WritableBinaryHandle
+ DataFrame.IO.Utils.RandomAccess: resetPosition :: MemoryBuffer -> IO ()
+ DataFrame.IO.Utils.RandomAccess: withWritableBinaryFile :: FilePath -> (WritableBinaryHandle -> IO a) -> IO a
+ DataFrame.IO.Utils.RandomAccess: writeByteString :: MemoryBuffer -> ByteString -> IO ()
+ DataFrame.IO.Utils.RandomAccess: writeByteStringToFile :: WritableBinaryHandle -> ByteString -> IO ()
+ DataFrame.IO.Utils.RandomAccess: writeDoubleLE :: MemoryBuffer -> Double -> IO ()
+ DataFrame.IO.Utils.RandomAccess: writeFloatLE :: MemoryBuffer -> Float -> IO ()
+ DataFrame.IO.Utils.RandomAccess: writeInteger64 :: MemoryBuffer -> Integer -> IO ()
+ DataFrame.IO.Utils.RandomAccess: writeInteger64At :: MemoryBuffer -> Int -> Integer -> IO Int
+ DataFrame.IO.Utils.RandomAccess: writeWord32At :: MemoryBuffer -> Int -> Word32 -> IO ()
+ DataFrame.IO.Utils.RandomAccess: writeWord32LE :: MemoryBuffer -> Word32 -> IO ()
+ DataFrame.IO.Utils.RandomAccess: writeWord64At :: MemoryBuffer -> Int -> Word64 -> IO ()
+ DataFrame.IO.Utils.RandomAccess: writeWord64LE :: MemoryBuffer -> Word64 -> IO ()
+ DataFrame.IO.Utils.RandomAccess: writeWord8 :: MemoryBuffer -> Word8 -> IO ()

Files

+ benchmark/Writer10GB.hs view
@@ -0,0 +1,49 @@+module Main (main) where++import Control.DeepSeq (NFData (rnf))+import Criterion.Main (bench, defaultMain, envWithCleanup, whnfIO)+import DataFrame.IO.Parquet.Writer (writeParquet)+import DataFrame.Internal.DataFrame (DataFrame, forceDataFrame)+import DataFrame10GB (stressDataFrame)+import System.Directory (removeDirectoryRecursive)+import System.FilePath ((</>))+import System.IO.Temp (createTempDirectory, getCanonicalTemporaryDirectory)++data BenchmarkEnvironment = BenchmarkEnvironment+    { benchmarkDataFrame :: DataFrame+    , benchmarkDirectory :: FilePath+    , benchmarkOutput :: FilePath+    }++instance NFData BenchmarkEnvironment where+    rnf environment =+        forceDataFrame (benchmarkDataFrame environment) `seq`+            rnf (benchmarkDirectory environment) `seq`+                rnf (benchmarkOutput environment)++prepareEnvironment :: IO BenchmarkEnvironment+prepareEnvironment = do+    temporary <- getCanonicalTemporaryDirectory+    directory <- createTempDirectory temporary "dataframe-parquet-writer-10gb"+    pure+        BenchmarkEnvironment+            { benchmarkDataFrame = stressDataFrame+            , benchmarkDirectory = directory+            , benchmarkOutput = directory </> "benchmark.parquet"+            }++cleanupEnvironment :: BenchmarkEnvironment -> IO ()+cleanupEnvironment = removeDirectoryRecursive . benchmarkDirectory++main :: IO ()+main =+    defaultMain+        [ envWithCleanup prepareEnvironment cleanupEnvironment $ \environment ->+            -- Memory usage for this benchmark will be north of 20 GB.+            bench "write 10 GiB dataframe" $+                whnfIO+                    ( writeParquet+                        (benchmarkOutput environment)+                        (benchmarkDataFrame environment)+                    )+        ]
dataframe-parquet.cabal view
@@ -1,6 +1,6 @@ cabal-version:      3.4 name:               dataframe-parquet-version:            1.5.0.1+version:            1.5.1.0 synopsis:           Parquet reader and writer for the dataframe ecosystem. description:     @DataFrame.IO.Parquet@ — pure-Haskell Parquet 2.0 reader and writer@@ -19,6 +19,7 @@ copyright:          (c) 2024-2026 Michael Chavinda category:           Data tested-with:        GHC ==9.4.8 || ==9.6.7 || ==9.8.4 || ==9.10.3 || ==9.12.2+extra-source-files: tests/data/*.parquet  common warnings     ghc-options:@@ -28,6 +29,11 @@         -Wunused-local-binds         -Wunused-packages +flag stress-tests+    description:        Build and run the opt-in 10 GiB Parquet roundtrip stress test.+    default:            False+    manual:             True+ library     import:             warnings     ghc-options:        -O2@@ -44,6 +50,11 @@                         DataFrame.IO.Parquet.Thrift                         DataFrame.IO.Parquet.Time                         DataFrame.IO.Parquet.Utils+                        DataFrame.IO.Parquet.Writer+                        DataFrame.IO.Parquet.Writer.DefLevels+                        DataFrame.IO.Parquet.Writer.Encoder+                        DataFrame.IO.Parquet.Writer.Metadata+                        DataFrame.IO.Parquet.Writer.Options                         DataFrame.IO.Utils.RandomAccess                         DataFrame.Typed.IO.Parquet     build-depends:      base >= 4 && < 5,@@ -52,6 +63,7 @@                         dataframe-core >= 2.5 && < 2.6,                         dataframe-operations >= 2.5 && < 2.6,                         dataframe-parsing >= 2.2 && < 2.3,+                        primitive >= 0.7 && < 0.11,                         directory >= 1.3.0.0 && < 2,                         filepath >= 1.4 && < 2,                         Glob >= 0.10 && < 1,@@ -64,3 +76,56 @@                         zstd >= 0.1.2.0 && < 0.3     hs-source-dirs:     src     default-language:   Haskell2010+++test-suite dataframe-parquet-tests+    import:             warnings+    type:               exitcode-stdio-1.0+    main-is:            Main.hs+    hs-source-dirs:     tests+    build-depends:      base >= 4 && < 5,+                        bytestring >= 0.11 && < 0.14,+                        dataframe-core >= 2.5 && < 2.6,+                        dataframe-parquet,+                        directory >= 1.3.0.0 && < 2,+                        filepath >= 1.4 && < 2,+                        temporary >= 1.3 && < 1.5,+                        text >= 2.1 && < 3,+                        HUnit >= 1.6 && < 1.8+    default-language:   Haskell2010++executable dataframe-parquet-10gb-stress+    import:             warnings+    main-is:            StressMain.hs+    other-modules:      DataFrame10GB+    hs-source-dirs:     stress+    build-depends:      base >= 4 && < 5,+                        dataframe-core >= 2.5 && < 2.6,+                        dataframe-parquet,+                        filepath >= 1.4 && < 2,+                        temporary >= 1.3 && < 1.5,+                        text >= 2.1 && < 3,+                        time >= 1.12 && < 2,+                        vector >= 0.13 && < 0.15+    default-language:   Haskell2010+    -- ghc-options:        -O2 -threaded -rtsopts -with-rtsopts=-N++benchmark dataframe-parquet-writer-10gb+    import:             warnings+    type:               exitcode-stdio-1.0+    main-is:            Writer10GB.hs+    other-modules:      DataFrame10GB+    hs-source-dirs:     benchmark, stress+    build-depends:      base >= 4 && < 5,+                        criterion >= 1 && < 2,+                        deepseq >= 1.4 && < 2,+                        dataframe-core >= 2.5 && < 2.6,+                        dataframe-parquet,+                        directory >= 1.3 && < 2,+                        filepath >= 1.4 && < 2,+                        temporary >= 1.3 && < 1.5,+                        text >= 2.1 && < 3,+                        time >= 1.12 && < 2,+                        vector >= 0.13 && < 0.15+    default-language:   Haskell2010+    ghc-options:        -O2 -threaded -rtsopts -with-rtsopts=-N
src/DataFrame/IO/Parquet.hs view
@@ -1,3 +1,4 @@+{-# LANGUAGE AllowAmbiguousTypes #-} {-# LANGUAGE CPP #-} {-# LANGUAGE FlexibleContexts #-} {-# LANGUAGE MonoLocalBinds #-}@@ -7,8 +8,62 @@ {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE TypeApplications #-} -module DataFrame.IO.Parquet where+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,+) where+ import Control.Exception (throw) import Control.Monad import Control.Monad.IO.Class (MonadIO (..))@@ -48,7 +103,7 @@     int96Decoder,  ) import DataFrame.IO.Parquet.Seeking (-    FileBufferedOrSeekable,+    FileBufferedOrSeekable (..),     ForceNonSeekable,     withFileBufferedOrSeekable,  )@@ -56,6 +111,7 @@     ColumnChunk (..),     DecimalType (..),     FileMetadata (..),+    KeyValue (..),     LogicalType (..),     RowGroup (..),     ThriftType (..),@@ -74,6 +130,14 @@     generateColumnDescriptions,     getColumnNames,  )+import DataFrame.IO.Parquet.Writer (+    ParquetWriteOptions (..),+    WriterStrategy (..),+    defaultParquetWriteOptions,+    nativeTypeKeyPrefix,+    writeParquet,+    writeParquetWithOptions,+ ) import DataFrame.IO.Utils.RandomAccess (     RandomAccess (..),     ReaderIO (runReaderIO),@@ -89,6 +153,12 @@ import DataFrame.Internal.Expression (Expr, getColumns) import DataFrame.Operations.Merge () import qualified DataFrame.Operations.Subset as DS+import DataFrame.Schema (+    Schema (..),+    SchemaType,+    makeSchema,+    schemaType,+ ) import qualified Pinch import System.Directory (doesDirectoryExist) import System.FilePath ((</>))@@ -207,7 +277,7 @@      matches <- glob pat -    files <- filterM (fmap not . doesDirectoryExist) matches+    files <- L.sort <$> filterM (fmap not . doesDirectoryExist) matches      case files of         [] ->@@ -299,7 +369,12 @@      rawCols <- zipWithM (parseColumnChunks vectorLength) keptChunks keptDescs -    let finalCols = zipWith applyDescLogicalType keptDescs rawCols+    let hints = nativeTypeHints metadata+        finalCols =+            zipWith+                (restoreNativeType hints)+                keptNames+                (zipWith applyDescLogicalType keptDescs rawCols)         indices = Map.fromList $ zip keptNames [0 ..]         dimensions = (vectorLength, length finalCols) @@ -613,6 +688,55 @@                         Left _ -> 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 Map.lookup leaf (elements hints) of+    Just ty+        | stampedAs @Int ty -> narrow (fromIntegral @Int64 @Int)+        | stampedAs @Integer ty -> narrow (fromIntegral @Int64 @Integer)+    _ -> col+  where+    leaf = last (T.splitOn "." name)+    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 expressed as @ticksPerSecond@ ticks/second (each tick = @psPerTick@ picoseconds) to 'UTCTime', at full precision.
+ src/DataFrame/IO/Parquet/Writer.hs view
@@ -0,0 +1,413 @@+{-# LANGUAGE BangPatterns #-}+{-# LANGUAGE OverloadedRecordDot #-}+{-# LANGUAGE OverloadedStrings #-}++module DataFrame.IO.Parquet.Writer (+    writeParquet,+    writeParquetWithOptions,+    ParquetWriteOptions (..),+    WriterStrategy (..),+    defaultParquetWriteOptions,+    nativeTypeKeyPrefix,+    nativeTypeKeyValues,+) where++import Control.Monad (forM_, unless, when)+import qualified Data.ByteString as BS+import Data.IORef (IORef, modifyIORef', newIORef, readIORef, writeIORef)+import Data.Int (Int64)+import Data.Maybe (fromJust)+import Data.Primitive.ByteArray (getSizeofMutableByteArray)+import qualified Data.Text as T+import qualified Data.Vector as VB+import DataFrame.IO.Parquet.Thrift hiding (schema)+import DataFrame.IO.Parquet.Writer.DefLevels (+    DefLevels (..),+    flushDef,+    newDefLevels,+    pushDef,+ )+import DataFrame.IO.Parquet.Writer.Encoder (Encoder (..), buildEncoder)+import DataFrame.IO.Parquet.Writer.Metadata (+    magic,+    mkColumnChunk,+    mkDataPageHeader,+    mkRowGroup,+    mkSchemaElem,+    rootSchemaElement,+    writeFooter,+ )+import DataFrame.IO.Parquet.Writer.Options (+    ParquetWriteOptions (..),+    WriterStrategy (..),+    defaultParquetWriteOptions,+ )+import DataFrame.IO.Utils.RandomAccess (+    MemoryBuffer (..),+    WritableBinaryHandle,+    atomicallyWriteFile,+    bufferResidency,+    bufferToByteString,+    ensureCapacity,+    flushBufferToBuffer,+    flushBufferToFile,+    mallocBuffer,+    resetPosition,+    withWritableBinaryFile,+    writeByteString,+    writeByteStringToFile,+    writeWord32LE,+ )+import DataFrame.Internal.Column (Column, columnTypeString, hasMissing)+import DataFrame.Internal.DataFrame (+    DataFrame,+    columnNames,+    dataframeDimensions,+    getColumn,+ )+import qualified Pinch+import qualified Snappy+import System.Directory (createDirectoryIfMissing)+import System.FilePath (takeDirectory)+import Text.Printf (printf)++data ParquetWriterState = ParquetWriterState+    { outputFileHandle :: !WritableBinaryHandle+    , columnChunks :: !(VB.Vector ColumnChunkState)+    , currentFileOffsetRef :: !(IORef Int64)+    , scratchBuffer :: !MemoryBuffer+    , rowGroupMetadataRef :: !(IORef [RowGroup])+    , rowNumberRef :: !(IORef Int)+    }++data ColumnChunkState = ColumnChunkState+    { columnName :: !T.Text+    , nullable :: !Bool+    , schema :: !SchemaElement+    , encoder :: !Encoder+    , buffer :: !MemoryBuffer+    , uncompressedBufferSize :: !(IORef Int64)+    , pageState :: !PageState+    }++data PageState = PageState+    { pageBuffer :: !MemoryBuffer+    , definitionLevels :: !DefLevels+    , currentRowCount :: !(IORef Int)+    }++writeParquet :: FilePath -> DataFrame -> IO ()+writeParquet = writeParquetWithOptions defaultParquetWriteOptions++writeParquetWithOptions :: ParquetWriteOptions -> FilePath -> DataFrame -> IO ()+writeParquetWithOptions options path df = do+    when (options.strategy == TwoPass) $+        error+            "The Two Pass Strategy for the Parquet Writer has not yet been implemented"+    case options.compressionCodec of+        UNCOMPRESSED _ -> pure ()+        SNAPPY _ -> pure ()+        other -> error ("writeParquet: unsupported codec " <> show other)+    let (totalRows, _) = dataframeDimensions df+    case options.maxRowsPerFile of+        Nothing -> do+            when (isShardPattern path) $+                error+                    ( "writeParquet: path "+                        <> show path+                        <> " contains a '*' placeholder but maxRowsPerFile is not set"+                    )+            writeShard options path df 0 totalRows+        Just rowsPerFile -> do+            when (rowsPerFile <= 0) $+                error "writeParquet: maxRowsPerFile must be positive"+            unless (isShardPattern path) $+                error+                    ( "writeParquet: maxRowsPerFile requires a path with a '*' placeholder, got "+                        <> show path+                    )+            let starts = case [0, rowsPerFile .. totalRows - 1] of+                    [] -> [0] -- empty frame still produces one (empty) shard+                    ss -> ss+            forM_ (zip [0 ..] starts) $ \(shardIndex, start) -> do+                let shardPath = shardPathFor path shardIndex+                createDirectoryIfMissing True (takeDirectory shardPath)+                writeShard options shardPath df start (min totalRows (start + rowsPerFile))++isShardPattern :: FilePath -> Bool+isShardPattern = elem '*'++-- | Replace every @*@ in the pattern with a zero-padded shard index.+shardPathFor :: FilePath -> Int -> FilePath+shardPathFor pattern_ shardIndex =+    concatMap (\c -> if c == '*' then printf "%05d" shardIndex else [c]) pattern_++-- | Write rows @[startRow, endRow)@ of the frame to a single Parquet file.+writeShard ::+    ParquetWriteOptions -> FilePath -> DataFrame -> Int -> Int -> IO ()+writeShard options path_ df startRow endRow = do+    let names = columnNames df+        shardRows = max 0 (endRow - startRow)+    columnChunks_ <-+        VB.fromList+            <$> mapM+                ( \columnName_ ->+                    initColumnChunkState+                        options+                        columnName_+                        (fromJust (getColumn columnName_ df))+                )+                names+    scratchBuffer_ <- mallocBuffer (max 1 options.pageSize)+    atomicallyWriteFile path_ $ \path -> withWritableBinaryFile path $ \output -> do+        writeByteStringToFile output magic+        currentFileOffsetRef_ <- newIORef 4+        rowGroupMetadataRef_ <- newIORef []+        rowNumberRef_ <- newIORef 0+        let writerState =+                ParquetWriterState+                    output+                    columnChunks_+                    currentFileOffsetRef_+                    scratchBuffer_+                    rowGroupMetadataRef_+                    rowNumberRef_+            interval = max 1 options.batchRows+            subBatch = max 1 options.subBatchRows+            writeBatch :: Int -> Int -> IO ()+            writeBatch rowNum batchEnd+                | rowNum >= batchEnd = pure ()+                | otherwise = do+                    let count = min subBatch (batchEnd - rowNum)+                    VB.forM_ columnChunks_ (writeRows options scratchBuffer_ rowNum count)+                    modifyIORef' rowNumberRef_ (+ count)+                    writeBatch (rowNum + count) batchEnd+            loop :: Int -> IO ()+            loop rowNum+                | rowNum >= endRow = pure ()+                | otherwise = do+                    let batchEnd = rowNum + min interval (endRow - rowNum)+                    writeBatch rowNum batchEnd+                    size <- bufferedSize columnChunks_+                    when (size >= options.rowGroupSize) (flushRowGroup options writerState)+                    loop batchEnd+        loop startRow+        flushRowGroup options writerState+        rowGroupMetadata <- reverse <$> readIORef rowGroupMetadataRef_+        let schemaElements =+                rootSchemaElement (VB.length columnChunks_)+                    : VB.toList (VB.map schema columnChunks_)+        writeFooter+            output+            schemaElements+            shardRows+            rowGroupMetadata+            (nativeTypeKeyValues names df)++nativeTypeKeyPrefix :: T.Text+nativeTypeKeyPrefix = "dataframe.type."++-- | The type stamp for every column of @df@, as footer key-value pairs.+nativeTypeKeyValues :: [T.Text] -> DataFrame -> [(T.Text, T.Text)]+nativeTypeKeyValues names df =+    [ (nativeTypeKeyPrefix <> name, T.pack (columnTypeString col))+    | name <- names+    , Just col <- [getColumn name df]+    ]++writeRows ::+    ParquetWriteOptions -> MemoryBuffer -> Int -> Int -> ColumnChunkState -> IO ()+writeRows options scratch firstRow count ccs = do+    let page = ccs.pageState+        buf = page.pageBuffer+        encode = ccs.encoder.encodeValue+        dl = page.definitionLevels+        end = firstRow + count++    pos0 <- readIORef buf.positionRef+    let margin = options.pageSize+    arr0 <- ensureCapacity buf (pos0 + max margin (count * 64))+    size0 <- getSizeofMutableByteArray arr0++    let go !size !pos !row+            | row >= end = writeIORef buf.positionRef pos+            | pos + margin > size = do+                -- Rare: buffer nearly full, grow it+                writeIORef buf.positionRef pos+                arr' <- ensureCapacity buf (pos + max margin ((end - row) * 64))+                size' <- getSizeofMutableByteArray arr'+                go size' pos row+            | otherwise = do+                (pos', notNull) <- encode buf pos row+                when ccs.nullable $+                    pushDef dl (if notNull then 1 else 0)+                go size pos' (row + 1)++    go size0 pos0 firstRow++    -- Batch bookkeeping: once per sub-batch instead of per value+    modifyIORef' page.currentRowCount (+ count)+    flushDef dl+    pageRes <- bufferResidency buf+    defRes <- bufferResidency dl.dlBuf+    when+        (pageRes + defRes >= options.pageSize)+        (flushPage options scratch ccs)++flushPage :: ParquetWriteOptions -> MemoryBuffer -> ColumnChunkState -> IO ()+flushPage options scratch columnChunkState = do+    let page = columnChunkState.pageState+    numPageRows <- readIORef page.currentRowCount+    when (numPageRows > 0) $ do+        pos <- readIORef page.pageBuffer.positionRef+        pos' <- columnChunkState.encoder.finishValues page.pageBuffer pos+        writeIORef page.pageBuffer.positionRef pos'+        body <- assemblePageBody scratch columnChunkState+        writeDataPage options.compressionCodec numPageRows body columnChunkState+        resetPosition page.pageBuffer+        resetPosition page.definitionLevels.dlBuf+        resetPosition scratch+        writeIORef page.currentRowCount 0++assemblePageBody :: MemoryBuffer -> ColumnChunkState -> IO MemoryBuffer+assemblePageBody scratch columnChunkState+    | not columnChunkState.nullable = pure columnChunkState.pageState.pageBuffer+    | otherwise = do+        let page = columnChunkState.pageState+        flushDef page.definitionLevels+        resetPosition scratch+        defLevelsSize <- bufferResidency page.definitionLevels.dlBuf+        writeWord32LE scratch (fromIntegral defLevelsSize)+        flushBufferToBuffer page.definitionLevels.dlBuf scratch+        flushBufferToBuffer page.pageBuffer scratch+        pure scratch++writeDataPage ::+    CompressionCodec -> Int -> MemoryBuffer -> ColumnChunkState -> IO ()+writeDataPage codec numPageRows body columnChunkState = do+    uncompressedPageSize <- bufferResidency body+    compressedBody <- case codec of+        UNCOMPRESSED _ -> pure Nothing+        SNAPPY _ -> Just . Snappy.compress <$> bufferToByteString body+        other -> error ("writeParquet: unsupported codec " <> show other)+    let compressedPageSize = maybe uncompressedPageSize BS.length compressedBody+        headerBytes =+            Pinch.encode+                Pinch.compactProtocol+                (mkDataPageHeader numPageRows uncompressedPageSize compressedPageSize)+    writeByteString columnChunkState.buffer headerBytes+    case compressedBody of+        Nothing -> flushBufferToBuffer body columnChunkState.buffer+        Just bytes -> writeByteString columnChunkState.buffer bytes+    modifyIORef'+        columnChunkState.uncompressedBufferSize+        (+ fromIntegral (BS.length headerBytes + uncompressedPageSize))++flushRowGroup :: ParquetWriteOptions -> ParquetWriterState -> IO ()+flushRowGroup options writerState = do+    rowNumber <- readIORef writerState.rowNumberRef+    when (rowNumber > 0) $ do+        VB.forM_+            writerState.columnChunks+            (flushPage options writerState.scratchBuffer)+        (reversedColumnChunks, totalCompressed, totalUncompressed) <-+            VB.foldM'+                ( \(acc, totalCompressedSize, totalUncompressedSize) columnChunkState -> do+                    offset <- readIORef writerState.currentFileOffsetRef+                    compressedSize <- bufferResidency columnChunkState.buffer+                    uncompressedSize <- readIORef columnChunkState.uncompressedBufferSize+                    flushBufferToFile writerState.outputFileHandle columnChunkState.buffer+                    writeIORef+                        writerState.currentFileOffsetRef+                        (offset + fromIntegral compressedSize)+                    writeIORef columnChunkState.uncompressedBufferSize 0+                    let columnChunk =+                            mkColumnChunk+                                options.compressionCodec+                                columnChunkState.encoder.encType+                                columnChunkState.columnName+                                offset+                                compressedSize+                                uncompressedSize+                                rowNumber+                    pure+                        ( columnChunk : acc+                        , totalCompressedSize + fromIntegral compressedSize+                        , totalUncompressedSize + uncompressedSize+                        )+                )+                ([], 0 :: Int64, 0 :: Int64)+                writerState.columnChunks+        modifyIORef'+            writerState.rowGroupMetadataRef+            ( mkRowGroup+                (reverse reversedColumnChunks)+                totalCompressed+                totalUncompressed+                rowNumber+                :+            )+        writeIORef writerState.rowNumberRef 0++bufferedSize :: VB.Vector ColumnChunkState -> IO Int+bufferedSize =+    VB.foldM'+        ( \total columnChunkState -> do+            chunkSize <- bufferResidency columnChunkState.buffer+            valuesSize <- bufferResidency columnChunkState.pageState.pageBuffer+            defLevelsSize <-+                bufferResidency columnChunkState.pageState.definitionLevels.dlBuf+            pure (total + chunkSize + valuesSize + defLevelsSize)+        )+        0++initColumnChunkState ::+    ParquetWriteOptions -> T.Text -> Column -> IO ColumnChunkState+initColumnChunkState options columnName_ column = do+    encoder_ <- buildEncoder column+    let nullable_ = hasMissing column+        schema_ =+            mkSchemaElem+                columnName_+                encoder_.encType+                nullable_+                encoder_.convertedType+                encoder_.logicalType+        bufferSize = max 1 options.pageSize+    -- ColumnChunk Buffers start at page size and grow to their+    -- actual size over the course of building out the first row+    -- group.+    -- Each column chunk in a row group must have the same number+    -- of rows, but each column chunk is liable to fit the same+    -- number of rows in varying amounts of data depending on the+    -- encoding and the compression characteristics of the data.+    -- So the optimal buffer size of each column chunk is liable+    -- to vary+    -- As a result while one specific column chunk in a row group+    -- is likely to hit the page limit, the others are liable to be+    -- much smaller than the limit.+    buffer_ <- mallocBuffer bufferSize+    uncompressedBufferSize_ <- newIORef 0+    pageState_ <- initPageState bufferSize+    pure+        ColumnChunkState+            { columnName = columnName_+            , nullable = nullable_+            , schema = schema_+            , encoder = encoder_+            , buffer = buffer_+            , uncompressedBufferSize = uncompressedBufferSize_+            , pageState = pageState_+            }++initPageState :: Int -> IO PageState+initPageState bufferSize = do+    pageBuffer_ <- mallocBuffer bufferSize+    definitionLevels_ <- newDefLevels+    currentRowCount_ <- newIORef 0+    pure+        PageState+            { pageBuffer = pageBuffer_+            , definitionLevels = definitionLevels_+            , currentRowCount = currentRowCount_+            }
+ src/DataFrame/IO/Parquet/Writer/DefLevels.hs view
@@ -0,0 +1,61 @@+{-# LANGUAGE OverloadedRecordDot #-}++module DataFrame.IO.Parquet.Writer.DefLevels (+    DefLevels (..),+    newDefLevels,+    pushDef,+    flushDef,+) where++import Control.Monad (when)+import Data.Bits (shiftL, shiftR, (.&.), (.|.))+import Data.IORef (IORef, newIORef, readIORef, writeIORef)+import Data.Word (Word64)+import DataFrame.IO.Utils.RandomAccess (MemoryBuffer, mallocBuffer, writeWord8)++data DefLevels = DefLevels+    { dlBuf :: !MemoryBuffer+    , dlValue :: !(IORef Int)+    , dlCount :: !(IORef Int)+    }++newDefLevels :: IO DefLevels+newDefLevels = DefLevels <$> mallocBuffer 64 <*> newIORef 0 <*> newIORef 0++pushDef :: DefLevels -> Int -> IO ()+pushDef dl value = do+    count <- readIORef dl.dlCount+    if count == 0+        then writeIORef dl.dlValue value >> writeIORef dl.dlCount 1+        else do+            current <- readIORef dl.dlValue+            if current == value+                then writeIORef dl.dlCount (count + 1)+                else do+                    writeDefRun dl current count+                    writeIORef dl.dlValue value+                    writeIORef dl.dlCount 1+{-# INLINE pushDef #-}++flushDef :: DefLevels -> IO ()+flushDef dl = do+    count <- readIORef dl.dlCount+    when (count > 0) $ do+        value <- readIORef dl.dlValue+        writeDefRun dl value count+    writeIORef dl.dlCount 0+{-# INLINE flushDef #-}++writeDefRun :: DefLevels -> Int -> Int -> IO ()+writeDefRun dl value count = do+    writeLeb128 dl.dlBuf (fromIntegral (count `shiftL` 1))+    writeWord8 dl.dlBuf (fromIntegral value)+{-# INLINE writeDefRun #-}++writeLeb128 :: MemoryBuffer -> Word64 -> IO ()+writeLeb128 buffer value+    | value < 0x80 = writeWord8 buffer (fromIntegral value)+    | otherwise = do+        writeWord8 buffer (fromIntegral (value .&. 0x7f) .|. 0x80)+        writeLeb128 buffer (value `shiftR` 7)+{-# INLINE writeLeb128 #-}
+ src/DataFrame/IO/Parquet/Writer/Encoder.hs view
@@ -0,0 +1,394 @@+{-# LANGUAGE FlexibleContexts #-}+{-# LANGUAGE GADTs #-}+{-# LANGUAGE OverloadedRecordDot #-}+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE ScopedTypeVariables #-}+{-# LANGUAGE TypeApplications #-}++module DataFrame.IO.Parquet.Writer.Encoder (+    Encoder (..),+    buildEncoder,+) where++import Control.Monad.ST (stToIO)+import Data.Bits (shiftL, (.|.))+import Data.IORef (newIORef, readIORef, writeIORef)+import Data.Int (Int32, Int64)+import Data.Primitive.ByteArray (+    withMutableByteArrayContents,+    writeByteArray,+ )+import qualified Data.Text as T+import qualified Data.Text.Array as TA+import Data.Text.Internal (Text (Text))+import Data.Time.Calendar (toModifiedJulianDay)+import Data.Time.Clock (UTCTime (UTCTime), diffTimeToPicoseconds)+import Data.Type.Equality (TestEquality (..), (:~:) (Refl))+import qualified Data.Vector as VB+import qualified Data.Vector.Unboxed as VU+import Data.Word (Word8)+import DataFrame.IO.Parquet.Thrift+import DataFrame.IO.Utils.RandomAccess (+    MemoryBuffer (..),+    ensureCapacity,+    writeInteger64At,+    writeWord32At,+    writeWord64At,+ )+import DataFrame.Internal.Column (+    Column (..),+    Columnable,+    columnTypeString,+    hasElemType,+ )+import DataFrame.Internal.Column.Bitmap (+    Bitmap,+    bitmapTestBit,+ )+import DataFrame.Internal.Data.PackedText (+    PackedTextData (..),+    offAt,+    selAt,+ )+import Foreign (plusPtr)+import GHC.Float (castDoubleToWord64, castFloatToWord32)+import Pinch (enum, putField)+import Type.Reflection (typeRep)++data Encoder = Encoder+    { encType :: !ThriftType+    , convertedType :: !(Maybe ConvertedType)+    , logicalType :: !(Maybe LogicalType)+    , encodeValue :: !(MemoryBuffer -> Int -> Int -> IO (Int, Bool))+    , finishValues :: !(MemoryBuffer -> Int -> IO Int)+    }++buildEncoder :: Column -> IO Encoder+buildEncoder col+    | hasElemType @Int32 col =+        pure $+            scalarEncoder @Int32+                (INT32 enum)+                Nothing+                Nothing+                (\buffer pos v -> writeWord32At buffer pos (fromIntegral v) >> pure (pos + 4))+                col+    | hasElemType @Int64 col =+        pure $+            scalarEncoder @Int64+                (INT64 enum)+                Nothing+                Nothing+                (\buffer pos v -> writeWord64At buffer pos (fromIntegral v) >> pure (pos + 8))+                col+    -- Ints in GHC can be 32 bit or 64 bit integers depending on the+    -- underlying computers architecture. So we'll do 64bit integers+    -- to cover all our bases+    | hasElemType @Int col =+        pure $+            scalarEncoder @Int+                (INT64 enum)+                Nothing+                Nothing+                (\buffer pos v -> writeWord64At buffer pos (fromIntegral v) >> pure (pos + 8))+                col+    | hasElemType @Integer col =+        pure $+            scalarEncoder @Integer+                (INT64 enum)+                Nothing+                Nothing+                writeInteger64At+                col+    | hasElemType @Float col =+        pure $+            scalarEncoder @Float+                (FLOAT enum)+                Nothing+                Nothing+                ( \buffer pos v -> writeWord32At buffer pos (castFloatToWord32 v) >> pure (pos + 4)+                )+                col+    | hasElemType @Double col =+        pure $+            scalarEncoder @Double+                (DOUBLE enum)+                Nothing+                Nothing+                ( \buffer pos v -> writeWord64At buffer pos (castDoubleToWord64 v) >> pure (pos + 8)+                )+                col+    | hasElemType @Bool col = boolEncoder col+    | hasElemType @T.Text col = pure (textEncoder col)+    | hasElemType @UTCTime col = pure (timestampEncoder col)+    | otherwise =+        error ("writeParquet: unsupported column type " <> columnTypeString col)++scalarEncoder ::+    forall a.+    (Columnable a) =>+    ThriftType ->+    Maybe ConvertedType ->+    Maybe LogicalType ->+    (MemoryBuffer -> Int -> a -> IO Int) ->+    Column ->+    Encoder+scalarEncoder tt conv logical writePrim col =+    Encoder tt conv logical (columnWriter @a col writePrim) (\_ pos -> pure pos)+{-# INLINEABLE scalarEncoder #-}+{-# SPECIALIZE scalarEncoder ::+    ThriftType ->+    Maybe ConvertedType ->+    Maybe LogicalType ->+    (MemoryBuffer -> Int -> Int32 -> IO Int) ->+    Column ->+    Encoder+    #-}+{-# SPECIALIZE scalarEncoder ::+    ThriftType ->+    Maybe ConvertedType ->+    Maybe LogicalType ->+    (MemoryBuffer -> Int -> Int64 -> IO Int) ->+    Column ->+    Encoder+    #-}+{-# SPECIALIZE scalarEncoder ::+    ThriftType ->+    Maybe ConvertedType ->+    Maybe LogicalType ->+    (MemoryBuffer -> Int -> Float -> IO Int) ->+    Column ->+    Encoder+    #-}+{-# SPECIALIZE scalarEncoder ::+    ThriftType ->+    Maybe ConvertedType ->+    Maybe LogicalType ->+    (MemoryBuffer -> Int -> Double -> IO Int) ->+    Column ->+    Encoder+    #-}+{-# SPECIALIZE scalarEncoder ::+    ThriftType ->+    Maybe ConvertedType ->+    Maybe LogicalType ->+    (MemoryBuffer -> Int -> Int -> IO Int) ->+    Column ->+    Encoder+    #-}+{-# SPECIALIZE scalarEncoder ::+    ThriftType ->+    Maybe ConvertedType ->+    Maybe LogicalType ->+    (MemoryBuffer -> Int -> Integer -> IO Int) ->+    Column ->+    Encoder+    #-}++columnWriter ::+    forall a.+    (Columnable a) =>+    Column ->+    (MemoryBuffer -> Int -> a -> IO Int) ->+    MemoryBuffer ->+    Int ->+    Int ->+    IO (Int, Bool)+columnWriter col writePrim = case col of+    BoxedColumn bitmap (values :: VB.Vector b) ->+        case testEquality (typeRep @a) (typeRep @b) of+            Just Refl -> writeFrom bitmap (VB.unsafeIndex values)+            Nothing -> mismatch+    UnboxedColumn bitmap (values :: VU.Vector b) ->+        case testEquality (typeRep @a) (typeRep @b) of+            Just Refl -> writeFrom bitmap (VU.unsafeIndex values)+            Nothing -> mismatch+    _ -> mismatch+  where+    writeFrom bitmap at buffer pos row+        | isPresent bitmap row = do+            pos' <- writePrim buffer pos (at row)+            pure (pos', True)+        | otherwise = pure (pos, False)+    mismatch =+        error+            ("writeParquet: incompatible column representation for " <> columnTypeString col)+{-# INLINEABLE columnWriter #-}+{-# SPECIALIZE columnWriter ::+    Column ->+    (MemoryBuffer -> Int -> Int32 -> IO Int) ->+    MemoryBuffer ->+    Int ->+    Int ->+    IO (Int, Bool)+    #-}+{-# SPECIALIZE columnWriter ::+    Column ->+    (MemoryBuffer -> Int -> Int64 -> IO Int) ->+    MemoryBuffer ->+    Int ->+    Int ->+    IO (Int, Bool)+    #-}+{-# SPECIALIZE columnWriter ::+    Column ->+    (MemoryBuffer -> Int -> Float -> IO Int) ->+    MemoryBuffer ->+    Int ->+    Int ->+    IO (Int, Bool)+    #-}+{-# SPECIALIZE columnWriter ::+    Column ->+    (MemoryBuffer -> Int -> Double -> IO Int) ->+    MemoryBuffer ->+    Int ->+    Int ->+    IO (Int, Bool)+    #-}+{-# SPECIALIZE columnWriter ::+    Column ->+    (MemoryBuffer -> Int -> Bool -> IO Int) ->+    MemoryBuffer ->+    Int ->+    Int ->+    IO (Int, Bool)+    #-}+{-# SPECIALIZE columnWriter ::+    Column ->+    (MemoryBuffer -> Int -> UTCTime -> IO Int) ->+    MemoryBuffer ->+    Int ->+    Int ->+    IO (Int, Bool)+    #-}+{-# SPECIALIZE columnWriter ::+    Column ->+    (MemoryBuffer -> Int -> Int -> IO Int) ->+    MemoryBuffer ->+    Int ->+    Int ->+    IO (Int, Bool)+    #-}+{-# SPECIALIZE columnWriter ::+    Column ->+    (MemoryBuffer -> Int -> Integer -> IO Int) ->+    MemoryBuffer ->+    Int ->+    Int ->+    IO (Int, Bool)+    #-}++isPresent :: Maybe Bitmap -> Int -> Bool+isPresent Nothing _ = True+isPresent (Just bitmap) row = bitmapTestBit bitmap row+{-# INLINE isPresent #-}++boolEncoder :: Column -> IO Encoder+boolEncoder col = do+    bitsRef <- newIORef (0 :: Word8)+    countRef <- newIORef (0 :: Int)+    let addBit buffer pos value = do+            bits <- readIORef bitsRef+            count <- readIORef countRef+            let bits' = if value then bits .|. ((1 :: Word8) `shiftL` count) else bits+                count' = count + 1+            if count' == 8+                then do+                    arr <- readIORef buffer.arrayRef+                    writeByteArray arr pos bits'+                    writeIORef bitsRef 0+                    writeIORef countRef 0+                    pure (pos + 1)+                else do+                    writeIORef bitsRef bits'+                    writeIORef countRef count'+                    pure pos+        finish buffer pos = do+            count <- readIORef countRef+            pos' <-+                if count > 0+                    then do+                        bits <- readIORef bitsRef+                        arr <- readIORef buffer.arrayRef+                        writeByteArray arr pos bits+                        pure (pos + 1)+                    else pure pos+            writeIORef bitsRef 0+            writeIORef countRef 0+            pure pos'+    pure+        (Encoder (BOOLEAN enum) Nothing Nothing (columnWriter @Bool col addBit) finish)++textEncoder :: Column -> Encoder+textEncoder col =+    Encoder+        (BYTE_ARRAY enum)+        (Just (UTF8 enum))+        (Just (LT_STRING (putField StringType)))+        writePresent+        (\_ pos -> pure pos)+  where+    writePresent = case col of+        BoxedColumn bitmap (values :: VB.Vector a) ->+            case testEquality (typeRep @T.Text) (typeRep @a) of+                Just Refl -> writeBoxed bitmap values+                Nothing -> mismatch+        PackedText bitmap packed -> writePacked bitmap packed+        _ -> mismatch+    writeBoxed bitmap values buffer pos row+        | isPresent bitmap row = do+            let Text bytes offset count = VB.unsafeIndex values row+            pos' <- writeTextSlice buffer pos bytes offset count+            pure (pos', True)+        | otherwise = pure (pos, False)+    writePacked bitmap packed buffer pos row+        | isPresent bitmap row = do+            let baseRow = maybe row (`selAt` row) packed.ptSel+                start = offAt packed.ptOffsets baseRow+                end = offAt packed.ptOffsets (baseRow + 1)+            pos' <- writeTextSlice buffer pos packed.ptBytes start (end - start)+            pure (pos', True)+        | otherwise = pure (pos, False)+    writeTextSlice buffer pos bytes offset count = do+        writeIORef buffer.positionRef pos+        _ <- ensureCapacity buffer (pos + 4 + count)+        writeWord32At buffer pos (fromIntegral count)+        arr <- readIORef buffer.arrayRef+        withMutableByteArrayContents arr $ \ptr ->+            stToIO (TA.copyToPointer bytes offset (ptr `plusPtr` (pos + 4)) count)+        pure (pos + 4 + count)+    mismatch =+        error+            ("writeParquet: incompatible text representation for " <> columnTypeString col)++timestampEncoder :: Column -> Encoder+timestampEncoder col =+    Encoder+        (INT64 enum)+        (Just (TIMESTAMP_MICROS enum))+        (Just timestampLogical)+        (columnWriter @UTCTime col writeMicros)+        (\_ pos -> pure pos)+  where+    writeMicros buffer pos t = do+        writeWord64At buffer pos (fromIntegral (utcToMicros t))+        pure (pos + 8)++timestampLogical :: LogicalType+timestampLogical =+    LT_TIMESTAMP+        ( putField+            TimestampType+                { timestamp_isAdjustedToUTC = putField True+                , timestamp_unit = putField (MICROS (putField MicroSeconds))+                }+        )++utcToMicros :: UTCTime -> Int64+utcToMicros (UTCTime day dt) =+    fromIntegral+        ( (toModifiedJulianDay day - 40587) * 86400 * 1000000+            + diffTimeToPicoseconds dt `div` 1000000+        )+{-# INLINE utcToMicros #-}
+ src/DataFrame/IO/Parquet/Writer/Metadata.hs view
@@ -0,0 +1,175 @@+{-# LANGUAGE OverloadedStrings #-}++module DataFrame.IO.Parquet.Writer.Metadata (+    mkSchemaElem,+    rootSchemaElement,+    mkDataPageHeader,+    mkColumnChunk,+    mkRowGroup,+    writeFooter,+    magic,+) where++import qualified Data.ByteString as BS+import Data.Int (Int64)+import qualified Data.Text as T+import DataFrame.IO.Parquet.Thrift+import DataFrame.IO.Utils.RandomAccess (+    WritableBinaryHandle,+    flushBufferToFile,+    mallocBuffer,+    writeByteString,+    writeWord32LE,+ )+import Pinch (enum, putField)+import qualified Pinch++mkDataPageHeader :: Int -> Int -> Int -> PageHeader+mkDataPageHeader rows uncompressedSize compressedSize =+    PageHeader+        { ph_type = putField (DATA_PAGE enum)+        , ph_uncompressed_page_size = putField (fromIntegral uncompressedSize)+        , ph_compressed_page_size = putField (fromIntegral compressedSize)+        , ph_crc = putField Nothing+        , ph_data_page_header = putField (Just dph)+        , ph_index_page_header = putField Nothing+        , ph_dictionary_page_header = putField Nothing+        , ph_data_page_header_v2 = putField Nothing+        }+  where+    dph =+        DataPageHeader+            { dph_num_values = putField (fromIntegral rows)+            , dph_encoding = putField (PLAIN enum)+            , dph_definition_level_encoding = putField (RLE enum)+            , dph_repetition_level_encoding = putField (RLE enum)+            , dph_statistics = putField Nothing+            }++mkSchemaElem ::+    T.Text ->+    ThriftType ->+    Bool ->+    Maybe ConvertedType ->+    Maybe LogicalType ->+    SchemaElement+mkSchemaElem elementName elementType nullable converted logical =+    SchemaElement+        { schematype = putField (Just elementType)+        , type_length = putField Nothing+        , repetition_type =+            putField (Just (if nullable then OPTIONAL enum else REQUIRED enum))+        , name = putField elementName+        , num_children = putField Nothing+        , converted_type = putField converted+        , scale = putField Nothing+        , precision = putField Nothing+        , field_id = putField Nothing+        , logicalType = putField logical+        }++rootSchemaElement :: Int -> SchemaElement+rootSchemaElement count =+    SchemaElement+        { schematype = putField Nothing+        , type_length = putField Nothing+        , repetition_type = putField Nothing+        , name = putField "schema"+        , num_children = putField (Just (fromIntegral count))+        , converted_type = putField Nothing+        , scale = putField Nothing+        , precision = putField Nothing+        , field_id = putField Nothing+        , logicalType = putField Nothing+        }++mkColumnChunk ::+    CompressionCodec ->+    ThriftType ->+    T.Text ->+    Int64 ->+    Int ->+    Int64 ->+    Int ->+    ColumnChunk+mkColumnChunk codec columnType columnName offset compressedSize uncompressedSize rgRows =+    ColumnChunk+        { cc_file_path = putField Nothing+        , cc_file_offset = putField offset+        , cc_meta_data = putField (Just metadata)+        , cc_offset_index_offset = putField Nothing+        , cc_offset_index_length = putField Nothing+        , cc_column_index_offset = putField Nothing+        , cc_column_index_length = putField Nothing+        , cc_crypto_metadata = putField Nothing+        , cc_encrypted_column_metadata = putField Nothing+        }+  where+    metadata =+        ColumnMetaData+            { cmd_type = putField columnType+            , cmd_encodings = putField [PLAIN enum, RLE enum]+            , cmd_path_in_schema = putField [columnName]+            , cmd_codec = putField codec+            , cmd_num_values = putField (fromIntegral rgRows)+            , cmd_total_uncompressed_size = putField uncompressedSize+            , cmd_total_compressed_size = putField (fromIntegral compressedSize)+            , cmd_key_value_metadata = putField Nothing+            , cmd_data_page_offset = putField offset+            , cmd_index_page_offset = putField Nothing+            , cmd_dictionary_page_offset = putField Nothing+            , cmd_statistics = putField Nothing+            , cmd_encoding_stats = putField Nothing+            , cmd_bloom_filter_offset = putField Nothing+            , cmd_bloom_filter_length = putField Nothing+            }++mkRowGroup :: [ColumnChunk] -> Int64 -> Int64 -> Int -> RowGroup+mkRowGroup chunks totalCompressed totalUncompressed rgRows =+    RowGroup+        { rg_columns = putField chunks+        , rg_total_byte_size = putField totalUncompressed+        , rg_num_rows = putField (fromIntegral rgRows)+        , rg_sorting_columns = putField Nothing+        , rg_file_offset = putField Nothing+        , rg_total_compressed_size = putField (Just totalCompressed)+        , rg_ordinal = putField Nothing+        }++writeFooter ::+    WritableBinaryHandle ->+    [SchemaElement] ->+    Int ->+    [RowGroup] ->+    [(T.Text, T.Text)] ->+    IO ()+writeFooter output schemaElements numRows rowGroupMetadata keyValues = do+    let metadata =+            FileMetadata+                { version = putField 1+                , schema = putField schemaElements+                , num_rows = putField (fromIntegral numRows)+                , row_groups = putField rowGroupMetadata+                , key_value_metadata =+                    putField $+                        if null keyValues+                            then Nothing+                            else+                                Just+                                    [ KeyValue (putField k) (putField (Just v))+                                    | (k, v) <- keyValues+                                    ]+                , created_by = putField (Just "dataframe-parquet")+                , column_orders = putField Nothing+                , encryption_algorithm = putField Nothing+                , footer_signing_key_metadata = putField Nothing+                }+        footer = Pinch.encode Pinch.compactProtocol metadata+    buffer <- mallocBuffer (BS.length footer + 8)+    writeByteString buffer footer+    writeWord32LE buffer (fromIntegral (BS.length footer))+    writeByteString buffer magic+    flushBufferToFile output buffer++magic :: BS.ByteString+magic = "PAR1"
+ src/DataFrame/IO/Parquet/Writer/Options.hs view
@@ -0,0 +1,34 @@+module DataFrame.IO.Parquet.Writer.Options (+    ParquetWriteOptions (..),+    WriterStrategy (..),+    defaultParquetWriteOptions,+) where++import DataFrame.IO.Parquet.Thrift+import Pinch (enum)++data WriterStrategy = InMemory | TwoPass+    deriving (Eq, Show)++data ParquetWriteOptions = ParquetWriteOptions+    { pageSize :: !Int+    , rowGroupSize :: !Int+    , batchRows :: !Int+    , subBatchRows :: !Int+    , compressionCodec :: !CompressionCodec+    , strategy :: !WriterStrategy+    , maxRowsPerFile :: !(Maybe Int)+    }+    deriving (Eq, Show)++defaultParquetWriteOptions :: ParquetWriteOptions+defaultParquetWriteOptions =+    ParquetWriteOptions+        { pageSize = 1048576+        , rowGroupSize = 134217728+        , batchRows = 8192+        , subBatchRows = 2048+        , compressionCodec = SNAPPY enum+        , strategy = InMemory+        , maxRowsPerFile = Nothing+        }
src/DataFrame/IO/Utils/RandomAccess.hs view
@@ -1,21 +1,83 @@ {-# LANGUAGE FlexibleInstances #-}+{-# LANGUAGE OverloadedRecordDot #-} -module DataFrame.IO.Utils.RandomAccess where+module DataFrame.IO.Utils.RandomAccess (+    uncurry3,+    Range (..),+    RandomAccess (..),+    ReaderIO (runReaderIO),+    LocalFile,+    MMappedFile,+    unsafeToByteString,+    WritableBinaryHandle,+    openWritableBinaryFile,+    withWritableBinaryFile,+    atomicallyWriteFile,+    MemoryBuffer (..),+    ensureCapacity,+    mallocBuffer,+    writeByteString,+    appendTextArraySlice,+    writeWord8,+    writeWord32LE,+    writeWord64LE,+    writeInteger64,+    writeWord32At,+    writeWord64At,+    writeInteger64At,+    writeFloatLE,+    writeDoubleLE,+    bufferResidency,+    bufferToByteString,+    flushBufferToBuffer,+    resetPosition,+    flushBufferToFile,+    writeByteStringToFile,+) where +import Control.Exception (bracket, bracketOnError, finally)+import Control.Monad (when) import Control.Monad.IO.Class (MonadIO (..))-import Data.ByteString (ByteString)-import Data.ByteString.Internal (ByteString (PS))+import Control.Monad.Primitive (RealWorld)+import Control.Monad.ST (stToIO)+import Data.Bits (shiftR)+import qualified Data.ByteString as BS+import Data.ByteString.Internal (ByteString (PS), create)+import qualified Data.ByteString.Unsafe as BU+import Data.IORef (IORef, newIORef, readIORef, writeIORef)+import Data.Int (Int64)+import Data.Primitive.ByteArray (+    MutableByteArray,+    copyMutableByteArray,+    getSizeofMutableByteArray,+    newPinnedByteArray,+    withMutableByteArrayContents,+    writeByteArray,+ )+import qualified Data.Text.Array as TA import qualified Data.Vector.Storable as VS-import Data.Word (Word8)+import Data.Word (Word32, Word64, Word8) import DataFrame.IO.Parquet.Seeking (     FileBufferedOrSeekable,     fGet,     fSeek,     readLastBytes,  )-import Foreign (castForeignPtr)+import Foreign (castForeignPtr, castPtr, copyBytes, plusPtr)+import GHC.Float (castDoubleToWord64, castFloatToWord32)+import System.Directory (copyPermissions, doesFileExist, removeFile, renameFile)+import System.FilePath (takeDirectory) import System.IO (+    BufferMode (NoBuffering),+    Handle,+    IOMode (WriteMode),     SeekMode (AbsoluteSeek),+    hClose,+    hPutBuf,+    hSetBinaryMode,+    hSetBuffering,+    openBinaryFile,+    openBinaryTempFileWithDefaultPermissions,  )  uncurry3 :: (a -> b -> c -> d) -> (a, b, c) -> d@@ -76,3 +138,274 @@ unsafeToByteString v = PS (castForeignPtr ptr) offset' len   where     (ptr, offset', len) = VS.unsafeToForeignPtr v++-- Writer Buffer -----------------------------------------------------------------++-- Refer to DataFrame.IO.Parquet.Writer for a justification of what we're doing here+-- There's some overlap here with what's going on in Seeking.hs, so, if this bothers+-- us, eventually someone will have to come back and reconcile the writer buffer+-- approach with the reader oriented patterns in Seeking.hs.+--+-- We're using MutableByteArrays here for convenience and because we don't need+-- the more powerful abstractions vector provides (which uses ByteArrays internally)+--+-- since we want to use hPutBuf, we're going to need a Ptr, which means are ByteArrya+-- must be pinned. Now growing pinned arrays can be problematic, but in the vast majority+-- of cases we shouldn't be growing more than once, if that. See the docs for+-- Data.Primitive.ByteArray.byteArrayContents.++newtype WritableBinaryHandle = WritableBinaryHandle {unHandle :: Handle}++openWritableBinaryFile :: FilePath -> IO WritableBinaryHandle+openWritableBinaryFile filepath = do+    h <- openBinaryFile filepath WriteMode+    hSetBinaryMode h True+    hSetBuffering h NoBuffering+    pure . WritableBinaryHandle $ h++atomicallyWriteFile :: FilePath -> (FilePath -> IO a) -> IO a+atomicallyWriteFile path action =+    bracketOnError+        openAction+        removeFile+        ( \tmpFile -> do+            result <- action tmpFile+            renameFile tmpFile path+            pure result+        )+  where+    openAction =+        bracketOnError+            ( openBinaryTempFileWithDefaultPermissions+                (takeDirectory path)+                "dataframe-parquet.incomplete"+            )+            (\(tmpFile, h) -> hClose h `finally` removeFile tmpFile)+            ( \(tmpFile, h) -> do+                hClose h+                destinationExists <- doesFileExist path+                when destinationExists (copyPermissions path tmpFile)+                pure tmpFile+            )++withWritableBinaryFile :: FilePath -> (WritableBinaryHandle -> IO a) -> IO a+withWritableBinaryFile filepath =+    bracket+        (openWritableBinaryFile filepath)+        (hClose . unHandle)++data MemoryBuffer = MemoryBuffer+    { arrayRef :: !(IORef (MutableByteArray RealWorld))+    , positionRef :: !(IORef Int)+    }++mallocBuffer :: Int -> IO MemoryBuffer+mallocBuffer capacity+    | capacity < 0 = ioError $ userError "mallocBuffer: negative capacity"+    | otherwise = do+        array <- newPinnedByteArray capacity+        MemoryBuffer <$> newIORef array <*> newIORef 0++-- We're using pinned ByteArrays so we must+-- not use the grow function brovided by Data.Primitive+-- instead we must alloocate a new pinned ByteArray.+-- We might have been worried about heap fragmentation+-- because a single pinned object in a 4KB GHC block can+-- keep the whole plock alive but oyr buffers will tend to+-- be much larger than that.+-- But the memory usage will temporarily spike to 2.5x the size of+-- the buffer, but it should be fine since the current writer is single threaded+-- and grows *should* be rare.+-- If it becomes an issue we should start tracking an array of pointers+-- to buffers intsead of replacing them wholesale so grwoing a buffer+-- is just a matter of adding a new buffer to the array (which we can+-- pre-allocate to three elements to begin with and grow it only on the+-- off chance that a buffer required more than three grows).+ensureCapacity :: MemoryBuffer -> Int -> IO (MutableByteArray RealWorld)+ensureCapacity buffer needed = do+    array <- readIORef buffer.arrayRef+    maxSize <- getSizeofMutableByteArray array+    if needed <= maxSize+        then pure array+        else do+            position <- readIORef buffer.positionRef+            grown <- newPinnedByteArray (needed + (needed `div` 2))+            copyMutableByteArray grown 0 array 0 position+            writeIORef buffer.arrayRef grown+            pure grown+{-# INLINE ensureCapacity #-}++writeWord8 :: MemoryBuffer -> Word8 -> IO ()+writeWord8 buffer b = do+    position <- readIORef buffer.positionRef+    array <- ensureCapacity buffer (position + 1)+    writeByteArray array position b+    writeIORef buffer.positionRef (position + 1)+{-# INLINE writeWord8 #-}++writeByteString :: MemoryBuffer -> ByteString -> IO ()+writeByteString buffer bs =+    BU.unsafeUseAsCStringLen bs $ \(source, len) -> do+        position <- readIORef buffer.positionRef+        array <- ensureCapacity buffer (position + len)+        withMutableByteArrayContents array $ \dst ->+            copyBytes (dst `plusPtr` position) (castPtr source) len+        writeIORef buffer.positionRef (position + len)+{-# INLINE writeByteString #-}++writeWord32LE :: MemoryBuffer -> Word32 -> IO ()+writeWord32LE buffer w = do+    position <- readIORef buffer.positionRef+    writeWord32At buffer position w+    writeIORef buffer.positionRef (position + 4)+{-# INLINE writeWord32LE #-}++writeWord64LE :: MemoryBuffer -> Word64 -> IO ()+writeWord64LE buffer w = do+    position <- readIORef buffer.positionRef+    writeWord64At buffer position w+    writeIORef buffer.positionRef (position + 8)+{-# INLINE writeWord64LE #-}++writeWord32At :: MemoryBuffer -> Int -> Word32 -> IO ()+writeWord32At buffer position w = do+    array <- ensureCapacity buffer (position + 4)+    writeByteArray array position (fromIntegral w :: Word8)+    writeByteArray array (position + 1) (fromIntegral (w `shiftR` 8) :: Word8)+    writeByteArray array (position + 2) (fromIntegral (w `shiftR` 16) :: Word8)+    writeByteArray array (position + 3) (fromIntegral (w `shiftR` 24) :: Word8)+{-# INLINE writeWord32At #-}++writeWord64At :: MemoryBuffer -> Int -> Word64 -> IO ()+writeWord64At buffer position w = do+    array <- ensureCapacity buffer (position + 8)+    writeByteArray array position (fromIntegral w :: Word8)+    writeByteArray array (position + 1) (fromIntegral (w `shiftR` 8) :: Word8)+    writeByteArray array (position + 2) (fromIntegral (w `shiftR` 16) :: Word8)+    writeByteArray array (position + 3) (fromIntegral (w `shiftR` 24) :: Word8)+    writeByteArray array (position + 4) (fromIntegral (w `shiftR` 32) :: Word8)+    writeByteArray array (position + 5) (fromIntegral (w `shiftR` 40) :: Word8)+    writeByteArray array (position + 6) (fromIntegral (w `shiftR` 48) :: Word8)+    writeByteArray array (position + 7) (fromIntegral (w `shiftR` 56) :: Word8)+{-# INLINE writeWord64At #-}++writeInteger64 :: MemoryBuffer -> Integer -> IO ()+writeInteger64 buffer value = do+    position <- readIORef buffer.positionRef+    newPosition <- writeInteger64At buffer position value+    writeIORef buffer.positionRef newPosition+{-# INLINE writeInteger64 #-}++writeInteger64At :: MemoryBuffer -> Int -> Integer -> IO Int+writeInteger64At buffer position value+    | value < toInteger (minBound :: Int64) = outOfRange+    | value > toInteger (maxBound :: Int64) = outOfRange+    | otherwise = do+        writeWord64At buffer position (fromIntegral value)+        pure (position + 8)+  where+    outOfRange =+        ioError (userError "writeParquet: Integer value is outside the INT64 range")+{-# INLINE writeInteger64At #-}++writeFloatLE :: MemoryBuffer -> Float -> IO ()+writeFloatLE buffer = writeWord32LE buffer . castFloatToWord32+{-# INLINE writeFloatLE #-}++writeDoubleLE :: MemoryBuffer -> Double -> IO ()+writeDoubleLE buffer = writeWord64LE buffer . castDoubleToWord64+{-# INLINE writeDoubleLE #-}++flushBufferToBuffer :: MemoryBuffer -> MemoryBuffer -> IO ()+flushBufferToBuffer source destination+    | source.arrayRef == destination.arrayRef = pure ()+    | otherwise = do+        sourceArray <- readIORef source.arrayRef+        sourcePosition <- readIORef source.positionRef+        destinationPosition <- readIORef destination.positionRef+        destinationArray <-+            ensureCapacity destination (destinationPosition + sourcePosition)+        copyMutableByteArray+            destinationArray+            destinationPosition+            sourceArray+            0+            sourcePosition+        writeIORef destination.positionRef (destinationPosition + sourcePosition)+        writeIORef source.positionRef 0+{-# INLINE flushBufferToBuffer #-}++bufferToByteString :: MemoryBuffer -> IO ByteString+bufferToByteString buffer = do+    array <- readIORef buffer.arrayRef+    position <- readIORef buffer.positionRef+    create position $ \dst ->+        withMutableByteArrayContents array $ \src ->+            copyBytes dst (castPtr src) position++bufferResidency :: MemoryBuffer -> IO Int+bufferResidency buffer = readIORef buffer.positionRef+{-# INLINE bufferResidency #-}++resetPosition :: MemoryBuffer -> IO ()+resetPosition buffer = writeIORef buffer.positionRef 0+{-# INLINE resetPosition #-}++-- I tested write speeds by doing (on Apple Silicon)+-- `dd if=/dev/zero of=test bs={$n}k oflag=direct conv=fdatasync+-- Results:+--+-- ```+--    | block size | data (GiB) |  time (s) | GiB/s |+--    |------------|------------|-----------|-------|+--    | 4k         |       4.00 |     2.371 |  1.69 |+--    | 8k         |       4.00 |     1.486 |  2.69 |+--    | 16k        |       4.00 |     1.045 |  3.83 |+--    | 32k        |       4.00 |     0.740 |  5.40 |+--    | 64k        |       4.00 |     0.675 |  5.92 |+--    | 128k       |       4.00 |     0.669 |  5.98 |+--    | 256k       |       4.00 |     0.664 |  6.03 |+--    | 512k       |       4.00 |     0.670 |  5.97 |+--    | 1024k      |       4.00 |     0.664 |  6.02 |+--    | 4096k      |       4.00 |     0.668 |  5.99 |+-- ```+-- So when writing to a file to minimize syscall overhead while+-- trying not to create dirty pages in the kernel page cache, we'll+-- be flushing in 256 KiB chunks.+flushBufferToFile :: WritableBinaryHandle -> MemoryBuffer -> IO ()+flushBufferToFile (WritableBinaryHandle h) buffer = do+    array <- readIORef buffer.arrayRef+    position <- readIORef buffer.positionRef+    withMutableByteArrayContents array $ \ptr -> do+        let chunkSize = 262144+            go offset+                | offset >= position = pure ()+                | otherwise = do+                    let n = min chunkSize (position - offset)+                    hPutBuf h (ptr `plusPtr` offset) n+                    go (offset + n)+        go 0+    writeIORef buffer.positionRef 0++writeByteStringToFile :: WritableBinaryHandle -> ByteString -> IO ()+writeByteStringToFile (WritableBinaryHandle h) bs =+    BU.unsafeUseAsCStringLen bs $ \(source, len) -> do+        let chunkSize = 262144+            go offset+                | offset >= len = pure ()+                | otherwise = do+                    let n = min chunkSize (len - offset)+                    hPutBuf h (source `plusPtr` offset) n+                    go (offset + n)+        go 0++appendTextArraySlice :: MemoryBuffer -> TA.Array -> Int -> Int -> IO ()+appendTextArraySlice buffer source offset count+    | count < 0 = ioError $ userError "appendTextArraySlice: negative length"+    | otherwise = do+        position <- readIORef buffer.positionRef+        array <- ensureCapacity buffer (position + count)+        withMutableByteArrayContents array $ \destination ->+            stToIO (TA.copyToPointer source offset (destination `plusPtr` position) count)+        writeIORef buffer.positionRef (position + count)+{-# INLINE appendTextArraySlice #-}
+ stress/DataFrame10GB.hs view
@@ -0,0 +1,137 @@+{-# LANGUAGE BangPatterns #-}+{-# LANGUAGE NumericUnderscores #-}++module DataFrame10GB (+    stressDataFrame,+    stressRows,+    stressColumns,+    stressResidentBytesLowerBound,+) where++import Control.Monad.ST (runST)+import Data.Int (Int32, Int64)+import qualified Data.Text as T+import qualified Data.Text.Array as A+import Data.Time (UTCTime (UTCTime), addDays, fromGregorian, secondsToDiffTime)+import qualified Data.Vector as VB+import qualified Data.Vector.Unboxed as VU+import Data.Word (Word8)+import DataFrame.Internal.Column (Column (..))+import DataFrame.Internal.Column.Bitmap (Bitmap)+import DataFrame.Internal.Data.PackedText (mkPackedContiguous32)+import DataFrame.Internal.DataFrame (DataFrame, fromNamedColumns)++stressRows :: Int+stressRows = 1_000_000++stressGroups :: Int+stressGroups = 16++stressColumns :: Int+stressColumns = stressGroups * 14++textBytesPerRow :: Int+textBytesPerRow = 320++stressResidentBytesLowerBound :: Integer+stressResidentBytesLowerBound =+    fromIntegral stressRows+        * fromIntegral stressGroups+        * fromIntegral (2 * textBytesPerRow + 2 * (4 + 8 + 4 + 8))++stressDataFrame :: DataFrame+stressDataFrame = fromNamedColumns (concatMap columnGroup [0 .. stressGroups - 1])++columnGroup :: Int -> [(T.Text, Column)]+columnGroup group =+    [ named "int32" (UnboxedColumn Nothing (int32Values group))+    , named "int64" (UnboxedColumn Nothing (int64Values group))+    , named "float" (UnboxedColumn Nothing (floatValues group))+    , named "double" (UnboxedColumn Nothing (doubleValues group))+    , named "bool" (UnboxedColumn Nothing (boolValues group))+    , named "timestamp" (BoxedColumn Nothing (timestampValues group))+    , named "text" (textColumn Nothing group)+    , named+        "nullable_int32"+        (UnboxedColumn (Just nullableBitmap) (int32Values (group + stressGroups)))+    , named+        "nullable_int64"+        (UnboxedColumn (Just nullableBitmap) (int64Values (group + stressGroups)))+    , named+        "nullable_float"+        (UnboxedColumn (Just nullableBitmap) (floatValues (group + stressGroups)))+    , named+        "nullable_double"+        (UnboxedColumn (Just nullableBitmap) (doubleValues (group + stressGroups)))+    , named+        "nullable_bool"+        (UnboxedColumn (Just nullableBitmap) (boolValues (group + stressGroups)))+    , named+        "nullable_timestamp"+        (BoxedColumn (Just nullableBitmap) (timestampValues (group + stressGroups)))+    , named "nullable_text" (textColumn (Just nullableBitmap) (group + stressGroups))+    ]+  where+    named suffix column = (T.pack ("group_" <> show group <> "_" <> suffix), column)++nullableBitmap :: Bitmap+nullableBitmap = VU.replicate (stressRows `div` 8) (0xFE :: Word8)++int32Values :: Int -> VU.Vector Int32+int32Values salt =+    VU.generate stressRows $ \row ->+        fromIntegral ((row + salt * 10_007) `mod` 2_000_001 - 1_000_000)++int64Values :: Int -> VU.Vector Int64+int64Values salt =+    VU.generate stressRows $ \row ->+        fromIntegral row * 1_000_003 - fromIntegral salt * 10_000_019++floatValues :: Int -> VU.Vector Float+floatValues salt =+    VU.generate stressRows $ \row ->+        fromIntegral ((row + salt * 101) `mod` 100_003) / 17++doubleValues :: Int -> VU.Vector Double+doubleValues salt =+    VU.generate stressRows $ \row ->+        fromIntegral row / 31.0 - fromIntegral salt * 1_000.25++boolValues :: Int -> VU.Vector Bool+boolValues salt = VU.generate stressRows (\row -> (row + salt) `mod` 3 == 0)++timestampValues :: Int -> VB.Vector UTCTime+timestampValues salt =+    VB.replicate+        stressRows+        ( UTCTime+            (addDays (fromIntegral salt) (fromGregorian 2020 1 1))+            (secondsToDiffTime (fromIntegral (salt * 1_337 `mod` 86_400)))+        )++textColumn :: Maybe Bitmap -> Int -> Column+textColumn bitmap salt = PackedText bitmap $ runST $ do+    target <- A.new (stressRows * textBytesPerRow)+    let template = textTemplate salt+        fill !row+            | row >= stressRows = pure ()+            | otherwise = do+                A.copyI textBytesPerRow target (row * textBytesPerRow) template 0+                fill (row + 1)+    fill 0+    bytes <- A.unsafeFreeze target+    let offsets =+            VU.generate+                (stressRows + 1)+                (\row -> fromIntegral (row * textBytesPerRow) :: Int32)+    pure (mkPackedContiguous32 bytes offsets)++textTemplate :: Int -> A.Array+textTemplate salt = A.run $ do+    bytes <- A.new textBytesPerRow+    let byte = fromIntegral (97 + salt `mod` 26)+        fill !index+            | index >= textBytesPerRow = pure ()+            | otherwise = A.unsafeWrite bytes index byte >> fill (index + 1)+    fill 0+    pure bytes
+ stress/StressMain.hs view
@@ -0,0 +1,38 @@+module Main (main) where++import Control.Exception (evaluate)+import Control.Monad (unless)+import DataFrame.IO.Parquet (readParquet)+import DataFrame.IO.Parquet.Writer (writeParquet)+import DataFrame.Internal.DataFrame (forceDataFrame)+import DataFrame10GB (+    stressColumns,+    stressDataFrame,+    stressResidentBytesLowerBound,+    stressRows,+ )+import System.Exit (exitFailure)+import System.FilePath ((</>))+import System.IO (hPutStrLn, stderr)+import System.IO.Temp (withSystemTempDirectory)++main :: IO ()+main = withSystemTempDirectory "dataframe-parquet-10gb-stress" $ \directory -> do+    expected <- evaluate (forceDataFrame stressDataFrame)+    let output = directory </> "roundtrip.parquet"+    putStrLn+        ( "writing "+            <> show stressRows+            <> " rows x "+            <> show stressColumns+            <> " columns (at least "+            <> show stressResidentBytesLowerBound+            <> " resident payload bytes)"+        )+    writeParquet output expected+    putStrLn "reading the stress dataframe"+    actual <- readParquet output+    putStrLn "checking dataframe equivalence"+    unless (expected == actual) $ do+        hPutStrLn stderr "10 GiB Parquet roundtrip mismatch"+        exitFailure
+ tests/Main.hs view
@@ -0,0 +1,283 @@+{-# LANGUAGE OverloadedStrings #-}++-- | Tests for the writer-buffer logic in "DataFrame.IO.Utils.RandomAccess".+module Main where++import Control.Exception (SomeException, catch, evaluate)+import qualified Data.ByteString as BS+import Data.List (sortOn)+import qualified System.Exit as Exit+import System.FilePath ((</>))+import System.IO.Temp (withSystemTempDirectory)+import Test.HUnit++import Control.Monad (unless)+import Data.Int (Int32, Int64)+import Data.Maybe (fromJust)+import qualified Data.Text as T+import DataFrame.IO.Parquet (readParquet, readParquetFiles)+import DataFrame.IO.Parquet.Writer (+    ParquetWriteOptions (..),+    defaultParquetWriteOptions,+    writeParquet,+    writeParquetWithOptions,+ )+import DataFrame.IO.Utils.RandomAccess+import DataFrame.Internal.Column (columnTypeString, fromList)+import DataFrame.Internal.DataFrame (+    DataFrame,+    columnNames,+    fromNamedColumns,+    getColumn,+ )+import System.Directory (listDirectory)++directWrites :: Test+directWrites = TestCase $ do+    buffer <- mallocBuffer 1+    writeWord8 buffer 0xaa+    writeWord32LE buffer 0x78563412+    writeWord64LE buffer 0x0807060504030201+    writeFloatLE buffer 1+    writeDoubleLE buffer 1+    writeByteString buffer (BS.pack [0xfe, 0xff])+    residency <- bufferResidency buffer+    bytes <- bufferToByteString buffer+    assertEqual "direct write residency" 27 residency+    assertEqual+        "direct write bytes"+        ( BS.pack+            [ 0xaa+            , 0x12+            , 0x34+            , 0x56+            , 0x78+            , 0x01+            , 0x02+            , 0x03+            , 0x04+            , 0x05+            , 0x06+            , 0x07+            , 0x08+            , 0x00+            , 0x00+            , 0x80+            , 0x3f+            , 0x00+            , 0x00+            , 0x00+            , 0x00+            , 0x00+            , 0x00+            , 0xf0+            , 0x3f+            , 0xfe+            , 0xff+            ]+        )+        bytes++directBufferFlush :: Test+directBufferFlush = TestCase $ do+    source <- mallocBuffer 0+    destination <- mallocBuffer 0+    writeByteString destination (BS.pack [1, 2])+    writeByteString source (BS.pack [3, 4, 5])+    flushBufferToBuffer source destination+    sourceResidency <- bufferResidency source+    destinationBytes <- bufferToByteString destination+    assertEqual "source cleared" 0 sourceResidency+    assertEqual "destination appended" (BS.pack [1, 2, 3, 4, 5]) destinationBytes+    flushBufferToBuffer destination destination+    selfFlushedBytes <- bufferToByteString destination+    assertEqual "self flush is a no-op" destinationBytes selfFlushedBytes+    resetPosition destination+    destinationResidency <- bufferResidency destination+    assertEqual "reset position" 0 destinationResidency++directFileFlush :: Test+directFileFlush = TestCase $+    withSystemTempDirectory "dfpq-buffer" $ \dir -> do+        let outPath = dir </> "out.bin"+            payload = BS.pack (take 300000 (cycle [0 .. 255]))+        buffer <- mallocBuffer 1+        writeByteString buffer payload+        withWritableBinaryFile outPath $ \output ->+            flushBufferToFile output buffer+        residency <- bufferResidency buffer+        contents <- BS.readFile outPath+        assertEqual "source cleared after file flush" 0 residency+        assertEqual "large payload round-trips" payload contents++writerRoundTrip :: String -> FilePath -> Test+writerRoundTrip label path = TestCase $+    withSystemTempDirectory "dfpq-writer" $ \dir -> do+        df <- readParquet path+        let out = dir </> "out.parquet"+        writeParquet out df+        df' <- readParquet out+        assertEqual label df df'++writerRoundTripTiny :: String -> FilePath -> Test+writerRoundTripTiny label path = TestCase $+    withSystemTempDirectory "dfpq-writer" $ \dir -> do+        df <- readParquet path+        let out = dir </> "out.parquet"+        writeParquetWithOptions tinyWriteOpts out df+        df' <- readParquet out+        assertEqual label df df'++writerRoundTripLargeText :: Test+writerRoundTripLargeText = TestCase $+    withSystemTempDirectory "dfpq-writer" $ \dir -> do+        let payload = T.replicate 4096 "abcdefgh"+            df = fromNamedColumns [("text", fromList [payload, "short"])]+            firstOut = dir </> "large-text-1.parquet"+            secondOut = dir </> "large-text-2.parquet"+        writeParquetWithOptions tinyWriteOpts firstOut df+        firstRoundTrip <- readParquet firstOut+        writeParquetWithOptions tinyWriteOpts secondOut firstRoundTrip+        secondRoundTrip <- readParquet secondOut+        assertEqual "large text first round-trip" df firstRoundTrip+        assertEqual "large text second round-trip" df secondRoundTrip++{- | Sharded writes: @maxRowsPerFile@ splits the frame across a glob pattern,+and reading the shards back reproduces the original frame.+-}+writerRoundTripSharded :: String -> FilePath -> Int -> Int -> Test+writerRoundTripSharded label path rowsPerFile expectedShards = TestCase $+    withSystemTempDirectory "dfpq-writer" $ \dir -> do+        df <- readParquet path+        let pattern_ = dir </> "shards" </> "part-*.parquet"+        writeParquetWithOptions+            defaultParquetWriteOptions{maxRowsPerFile = Just rowsPerFile}+            pattern_+            df+        shards <- listDirectory (dir </> "shards")+        assertEqual (label <> ": shard count") expectedShards (Prelude.length shards)+        assertEqual+            (label <> ": shard names")+            ["part-" <> pad i <> ".parquet" | i <- [0 .. expectedShards - 1]]+            (sortOn id shards)+        df' <- readParquetFiles pattern_+        assertEqual (label <> ": shards round-trip") df df'+  where+    pad i = let s = show i in replicate (5 - Prelude.length s) '0' <> s++-- | A path without a @*@ placeholder is rejected when sharding is requested.+shardedWriteRequiresPattern :: Test+shardedWriteRequiresPattern = TestCase $+    withSystemTempDirectory "dfpq-writer" $ \dir -> do+        df <- readParquet "tests/data/mtcars.parquet"+        threw <-+            ( False+                <$ writeParquetWithOptions+                    defaultParquetWriteOptions{maxRowsPerFile = Just 4}+                    (dir </> "out.parquet")+                    df+            )+                `catch` (\e -> True <$ evaluate (Prelude.length (show (e :: SomeException))))+        unless threw (assertFailure "expected an error for a path without '*'")++{- | Parquet has one 64-bit integer type, so @Int@, @Int64@ and @Integer@+columns all land in the file as @INT64@. The writer stamps the original+Haskell type in the footer so the reader can put it back; without that, a+CSV-inferred @Int@ column silently widens to @Int64@ on a round trip.+-}+writerRoundTripNativeIntTypes :: Test+writerRoundTripNativeIntTypes = TestCase $+    withSystemTempDirectory "dfpq-writer" $ \dir -> do+        let df =+                fromNamedColumns+                    [ ("int", fromList [1 :: Int, 2, 3])+                    , ("int64", fromList [1 :: Int64, 2, 3])+                    , ("int32", fromList [1 :: Int32, 2, 3])+                    , ("integer", fromList [1 :: Integer, 2, 3])+                    , ("nullableInt", fromList [Just (1 :: Int), Nothing, Just 3])+                    , ("nullableInt64", fromList [Just (1 :: Int64), Nothing, Just 3])+                    ]+            out = dir </> "int-types.parquet"+        writeParquet out df+        df' <- readParquet out+        assertEqual+            "native int types: column types"+            (columnTypes df)+            (columnTypes df')+        assertEqual "native int types: frame" df df'++columnTypes :: DataFrame -> [(String, String)]+columnTypes df =+    [ (T.unpack name, columnTypeString (fromJust (getColumn name df)))+    | name <- columnNames df+    ]++tinyWriteOpts :: ParquetWriteOptions+tinyWriteOpts =+    defaultParquetWriteOptions+        { pageSize = 64+        , rowGroupSize = 512+        , batchRows = 4+        , subBatchRows = 3+        }++tests :: Test+tests =+    TestList+        [ TestLabel "direct buffer writes" directWrites+        , TestLabel "direct buffer-to-buffer flush" directBufferFlush+        , TestLabel "direct buffer-to-file flush" directFileFlush+        , TestLabel+            "writer roundtrip: alltypes_plain"+            (writerRoundTrip "alltypes_plain" "tests/data/alltypes_plain.parquet")+        , TestLabel+            "writer roundtrip: alltypes_plain.snappy"+            ( writerRoundTrip+                "alltypes_plain.snappy"+                "tests/data/alltypes_plain.snappy.parquet"+            )+        , TestLabel+            "writer roundtrip: alltypes_dictionary"+            (writerRoundTrip "alltypes_dictionary" "tests/data/alltypes_dictionary.parquet")+        , TestLabel+            "writer roundtrip: alltypes_tiny_pages"+            (writerRoundTrip "alltypes_tiny_pages" "tests/data/alltypes_tiny_pages.parquet")+        , TestLabel+            "writer roundtrip: transactions"+            (writerRoundTrip "transactions" "tests/data/transactions.parquet")+        , TestLabel+            "writer roundtrip: mtcars"+            (writerRoundTrip "mtcars" "tests/data/mtcars.parquet")+        , TestLabel+            "writer roundtrip: int32_decimal"+            (writerRoundTrip "int32_decimal" "tests/data/int32_decimal.parquet")+        , TestLabel+            "writer roundtrip: int64_decimal"+            (writerRoundTrip "int64_decimal" "tests/data/int64_decimal.parquet")+        , TestLabel+            "writer roundtrip: sharded mtcars"+            (writerRoundTripSharded "sharded mtcars" "tests/data/mtcars.parquet" 10 4)+        , TestLabel+            "writer roundtrip: sharded exact multiple"+            (writerRoundTripSharded "sharded exact" "tests/data/mtcars.parquet" 32 1)+        , TestLabel+            "sharded write requires a '*' pattern"+            shardedWriteRequiresPattern+        , TestLabel+            "writer roundtrip: Int/Integer keep their Haskell type"+            writerRoundTripNativeIntTypes+        , TestLabel+            "writer roundtrip: alltypes_plain multi-page"+            ( writerRoundTripTiny+                "alltypes_plain multi-page"+                "tests/data/alltypes_plain.parquet"+            )+        , TestLabel "writer roundtrip: large text" writerRoundTripLargeText+        ]++main :: IO ()+main = do+    result <- runTestTT tests+    if failures result > 0 || errors result > 0+        then Exit.exitFailure+        else Exit.exitSuccess
+ tests/data/alltypes_dictionary.parquet view

binary file changed (absent → 1698 bytes)

+ tests/data/alltypes_plain.parquet view

binary file changed (absent → 1851 bytes)

+ tests/data/alltypes_plain.snappy.parquet view

binary file changed (absent → 1736 bytes)

+ tests/data/alltypes_tiny_pages.parquet view

binary file changed (absent → 454233 bytes)

+ tests/data/int32_decimal.parquet view

binary file changed (absent → 478 bytes)

+ tests/data/int64_decimal.parquet view

binary file changed (absent → 591 bytes)

+ tests/data/mtcars.parquet view

binary file changed (absent → 4564 bytes)

+ tests/data/transactions.parquet view

binary file changed (absent → 1746 bytes)