beam-duckdb-0.3.1.0: tests/Database/Beam/DuckDB/Test/Extensions/Copy.hs
{-# LANGUAGE DeriveAnyClass #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE StandaloneDeriving #-}
{-# LANGUAGE TypeFamilies #-}
module Database.Beam.DuckDB.Test.Extensions.Copy (tests) where
import Control.Monad (guard, void)
import Control.Monad.IO.Class (liftIO)
import qualified Data.Char as Char
import Data.Int (Int32)
import qualified Data.List as List
import Data.Text (Text)
import qualified Data.Text as Text
import qualified Data.Text.IO as Text.IO
import Database.Beam
( Beamable,
Columnar,
Database,
DatabaseSettings,
Generic,
Identity,
Table (..),
TableEntity,
all_,
asc_,
dbModification,
defaultDbSettings,
guard_,
insert,
insertValues,
modifyTableFields,
orderBy_,
runInsert,
runSelectReturningList,
select,
tableModification,
val_,
withDbModification,
(>.),
)
import Database.Beam.Backend.SQL.BeamExtensions
( copySelectTo,
copyTableFrom,
copyTableTo,
runCopyFrom,
runCopyTo,
)
import Database.Beam.DuckDB
( DuckDB,
DuckDBCSVCopyFromOptions (..),
DuckDBCSVCopyToOptions (..),
DuckDBJSONCopyFromOptions (..),
DuckDBJSONCopyToOptions (..),
DuckDBParquetCopyToOptions (..),
JSONCompression (..),
ParquetCompression (..),
copyFromCSV,
copyFromCSVWith,
copyFromJSONWith,
copyFromParquet,
copyFromParquetWith,
copyToCSV,
copyToCSVWith,
copyToJSONWith,
copyToParquetWith,
defaultDuckDBCSVCopyFromOptions,
defaultDuckDBCSVCopyToOptions,
defaultDuckDBJSONCopyFromOptions,
defaultDuckDBJSONCopyToOptions,
defaultDuckDBParquetCopyFromOptions,
defaultDuckDBParquetCopyToOptions,
runBeamDuckDB,
)
import Database.DuckDB.Simple (Connection, execute_, withConnection)
import Hedgehog (Gen, forAll, property, (===))
import qualified Hedgehog.Gen as Gen
import System.IO (hClose)
import System.IO.Temp (withSystemTempFile)
import Test.Tasty (TestTree, testGroup)
import Test.Tasty.HUnit (testCase, (@?=))
import Test.Tasty.Hedgehog (testProperty)
tests :: TestTree
tests =
testGroup
"COPY statements"
[ testGroup
"COPY ... TO"
[ testCopyAllColumnsToCsv,
testCopyMultiColumnProjectionToCsv,
testCopySingleColumnProjectionToCsv,
testCopySelectToCsv
],
testGroup
"COPY ... FROM"
[ testCopyFromCsvAllColumns,
testCopyFromCsvMultiColumnProjection,
testCopyFromCsvSingleColumnProjection
],
testGroup
"options"
[ testCopyToWithCustomDelimiterAndNoHeader,
testCopyFromWithCustomDelimiter,
testRoundTripParquetWithCompression
],
testGroup
"property-based round-trip"
[ propRoundTripCsv,
propRoundTripParquet,
propRoundTripJson
]
]
testCopyAllColumnsToCsv :: TestTree
testCopyAllColumnsToCsv = testCase "Copy all columns of a table to a CSV file" $
withCopyTarget $ \conn path -> do
runBeamDuckDB conn $
runCopyTo $
copyTableTo (_dbWidgets testDb) id (copyToCSV path)
(header, rows) <- readCsv path
header @?= "id,name,price"
rows
@?= List.sort
[ "1,Widget,9.99",
"2,Sprocket,4.5",
"3,Cog,1.25"
]
testCopyMultiColumnProjectionToCsv :: TestTree
testCopyMultiColumnProjectionToCsv =
testCase "Copy a multi-column projection of a table to a CSV file" $
withCopyTarget $ \conn path -> do
runBeamDuckDB conn $
runCopyTo $
copyTableTo
(_dbWidgets testDb)
(\w -> (_widgetId w, _widgetName w))
(copyToCSV path)
(header, rows) <- readCsv path
header @?= "id,name"
rows
@?= List.sort
[ "1,Widget",
"2,Sprocket",
"3,Cog"
]
testCopySingleColumnProjectionToCsv :: TestTree
testCopySingleColumnProjectionToCsv =
testCase "Copy a single column of a table to a CSV file" $
withCopyTarget $ \conn path -> do
runBeamDuckDB conn $
runCopyTo $
copyTableTo (_dbWidgets testDb) _widgetName (copyToCSV path)
(header, rows) <- readCsv path
header @?= "name"
rows @?= List.sort ["Widget", "Sprocket", "Cog"]
testCopySelectToCsv :: TestTree
testCopySelectToCsv =
testCase "Copy the result of a SELECT query to a CSV file" $
withCopyTarget $ \conn path -> do
runBeamDuckDB conn $
runCopyTo $
copySelectTo
( select $ do
w <- all_ (_dbWidgets testDb)
guard_ (_widgetPrice w >. val_ 4.0)
pure (_widgetName w)
)
(copyToCSV path)
(_header, rows) <- readCsv path
-- Only Widget (9.99) and Sprocket (4.5) have price > 4.0; Cog (1.25) is excluded.
rows @?= List.sort ["Widget", "Sprocket"]
testCopyFromCsvAllColumns :: TestTree
testCopyFromCsvAllColumns =
testCase "Copy all columns of a table from a CSV file" $
withCopyFromSource csvAllColumns $ \conn path -> do
runBeamDuckDB conn $
runCopyFrom $
copyTableFrom (_dbWidgets testDb) id (copyFromCSV path)
rows <- queryAllWidgets conn
rows
@?= [ Widget 1 "Widget" 9.99,
Widget 2 "Sprocket" 4.5,
Widget 3 "Cog" 1.25
]
where
csvAllColumns =
"id,name,price\n\
\1,Widget,9.99\n\
\2,Sprocket,4.5\n\
\3,Cog,1.25\n"
testCopyFromCsvMultiColumnProjection :: TestTree
testCopyFromCsvMultiColumnProjection =
testCase "Copy a multi-column projection of a table from a CSV file" $
withCopyFromSource csvIdName $ \conn path -> do
runBeamDuckDB conn $
runCopyFrom $
copyTableFrom
(_dbWidgets testDb)
(\w -> (_widgetId w, _widgetName w))
(copyFromCSV path)
rows <- queryAllWidgets conn
-- 'price' was not in the CSV, so it gets the table's DEFAULT value.
rows
@?= [ Widget 1 "Widget" 0,
Widget 2 "Sprocket" 0,
Widget 3 "Cog" 0
]
where
csvIdName =
"id,name\n\
\1,Widget\n\
\2,Sprocket\n\
\3,Cog\n"
testCopyFromCsvSingleColumnProjection :: TestTree
testCopyFromCsvSingleColumnProjection =
testCase "Copy a single column of a table from a CSV file" $
withCopyFromSource csvIdOnly $ \conn path -> do
runBeamDuckDB conn $
runCopyFrom $
copyTableFrom (_dbWidgets testDb) _widgetId (copyFromCSV path)
rows <- queryAllWidgets conn
-- Only 'id' was in the CSV, so 'name' and 'price' get table DEFAULTs.
rows
@?= [ Widget 1 "" 0,
Widget 2 "" 0,
Widget 3 "" 0
]
where
csvIdOnly =
"id\n\
\1\n\
\2\n\
\3\n"
testCopyToWithCustomDelimiterAndNoHeader :: TestTree
testCopyToWithCustomDelimiterAndNoHeader =
testCase "Copy a table to a CSV file with custom delimiter and no header" $
withCopyTarget $ \conn path -> do
let opts =
copyToCSVWith
path
defaultDuckDBCSVCopyToOptions
{ csvCopyToHeader = Just False,
csvCopyToDelimiter = Just "|"
}
runBeamDuckDB conn $
runCopyTo $
copyTableTo (_dbWidgets testDb) id opts
contents <- Text.IO.readFile path
let dataLines = List.sort (filter (not . Text.null) (Text.lines contents))
dataLines
@?= List.sort
[ "1|Widget|9.99",
"2|Sprocket|4.5",
"3|Cog|1.25"
]
testCopyFromWithCustomDelimiter :: TestTree
testCopyFromWithCustomDelimiter =
testCase "Copy from a CSV file with a custom delimiter" $
withCopyFromSource pipeDelimited $ \conn path -> do
let opts =
copyFromCSVWith
path
defaultDuckDBCSVCopyFromOptions
{ csvCopyFromDelimiter = Just "|"
}
runBeamDuckDB conn $
runCopyFrom $
copyTableFrom (_dbWidgets testDb) id opts
rows <- queryAllWidgets conn
rows
@?= [ Widget 1 "Widget" 9.99,
Widget 2 "Sprocket" 4.5,
Widget 3 "Cog" 1.25
]
where
pipeDelimited =
"id|name|price\n\
\1|Widget|9.99\n\
\2|Sprocket|4.5\n\
\3|Cog|1.25\n"
testRoundTripParquetWithCompression :: TestTree
testRoundTripParquetWithCompression =
testCase "Round-trip data through a ZSTD-compressed Parquet file" $
withTestDb widgetData $ \conn ->
withSystemTempFile "beam-duckdb-copy-.parquet" $ \path h -> do
hClose h
let toOpts =
copyToParquetWith
path
defaultDuckDBParquetCopyToOptions
{ parquetCopyToCompression = Just ParquetZstd
}
fromOpts = copyFromParquet path
runBeamDuckDB conn $
runCopyTo $
copyTableTo (_dbWidgets testDb) id toOpts
-- Wipe the seeded rows, then re-import from the Parquet file we wrote.
void $ execute_ conn "DELETE FROM widgets"
runBeamDuckDB conn $
runCopyFrom $
copyTableFrom (_dbWidgets testDb) id fromOpts
rows <- queryAllWidgets conn
rows
@?= [ Widget 1 "Widget" 9.99,
Widget 2 "Sprocket" 4.5,
Widget 3 "Cog" 1.25
]
propRoundTripCsv :: TestTree
propRoundTripCsv =
testProperty "CSV options round-trip" $
property $ do
toOpts <- forAll genCsvToOpts
let fromOpts = matchedCsvFromOpts toOpts
rows <- liftIO $ csvRoundTrip toOpts fromOpts
rows === widgetData
where
csvRoundTrip toOpts fromOpts =
withTestDb widgetData $ \conn ->
withSystemTempFile "beam-duckdb-prop-csv-.csv" $ \path h -> do
hClose h
runBeamDuckDB conn $
runCopyTo $
copyTableTo (_dbWidgets testDb) id (copyToCSVWith path toOpts)
void $ execute_ conn "DELETE FROM widgets"
runBeamDuckDB conn $
runCopyFrom $
copyTableFrom (_dbWidgets testDb) id (copyFromCSVWith path fromOpts)
queryAllWidgets conn
genCsvToOpts :: Hedgehog.Gen DuckDBCSVCopyToOptions
genCsvToOpts = do
hdr <- Gen.maybe Gen.bool
delim <- Gen.maybe (Gen.element [",", ";", "|", "\t"])
nullStr <- Gen.maybe (Gen.element ["NULL", "~~~"])
quote <- Gen.maybe genCsvSpecialChar
escape <- Gen.maybe genCsvSpecialChar
-- quote and escape can't overlap with nullStr and delim,
-- otherwise the CSV data may be unparseable.
guard (maybe True not $ Text.elem <$> quote <*> delim)
guard (quote /= Just ',') -- the default delimiter
guard (maybe True not $ Text.elem <$> quote <*> nullStr)
guard (maybe True not $ Text.elem <$> escape <*> delim)
guard (escape /= Just ',') -- the default delimiter
guard (maybe True not $ Text.elem <$> escape <*> nullStr)
pure
defaultDuckDBCSVCopyToOptions
{ csvCopyToHeader = hdr,
csvCopyToDelimiter = delim,
csvCopyToQuote = quote,
csvCopyToEscape = escape,
csvCopyToNullStr = nullStr,
-- The TO-side @overwrite_or_ignore@ field is intentionally fixed to
-- @Just True@: a property that replays through the same temp file 25
-- times needs the second iteration onwards to overwrite, and the field
-- itself is independent of the readable contents we round-trip.
csvCopyToOverwriteOrIgnore = Just True
}
matchedCsvFromOpts :: DuckDBCSVCopyToOptions -> DuckDBCSVCopyFromOptions
matchedCsvFromOpts o =
defaultDuckDBCSVCopyFromOptions
{ csvCopyFromHeader = csvCopyToHeader o,
csvCopyFromDelimiter = csvCopyToDelimiter o,
csvCopyFromQuote = csvCopyToQuote o,
csvCopyFromEscape = csvCopyToEscape o,
csvCopyFromNullStr = csvCopyToNullStr o
}
-- Printable single-byte ASCII characters that don't collide with
-- the seeded 'widgetData' (which is alphanumeric plus '.'), and the delimiter
genCsvSpecialChar :: Hedgehog.Gen Char
genCsvSpecialChar =
Gen.element
[ c
| c <- enumFromTo minBound maxBound,
Char.isPrint c,
Char.isAscii c,
not (Char.isAlphaNum c),
c /= '.'
]
propRoundTripParquet :: TestTree
propRoundTripParquet =
testProperty "Parquet options round-trip" $
property $ do
toOpts <- forAll genParquetToOpts
let fromOpts = defaultDuckDBParquetCopyFromOptions
rows <- liftIO $ parquetRoundTrip toOpts fromOpts
rows === widgetData
where
parquetRoundTrip toOpts fromOpts =
withTestDb widgetData $ \conn ->
withSystemTempFile "beam-duckdb-prop-parquet-.parquet" $ \path h -> do
hClose h
runBeamDuckDB conn $
runCopyTo $
copyTableTo (_dbWidgets testDb) id (copyToParquetWith path toOpts)
void $ execute_ conn "DELETE FROM widgets"
runBeamDuckDB conn $
runCopyFrom $
copyTableFrom (_dbWidgets testDb) id (copyFromParquetWith path fromOpts)
queryAllWidgets conn
genParquetToOpts :: Hedgehog.Gen DuckDBParquetCopyToOptions
genParquetToOpts = do
comp <- Gen.maybe Gen.enumBounded
pure
defaultDuckDBParquetCopyToOptions
{ parquetCopyToCompression = comp,
parquetCopyToOverwriteOrIgnore = Just True
}
propRoundTripJson :: TestTree
propRoundTripJson =
testProperty "JSON options round-trip" $
property $ do
toOpts <- forAll genJsonToOpts
fromOpts <- forAll (genJsonFromOpts (jsonCopyToCompression toOpts))
rows <- liftIO $ jsonRoundTrip toOpts fromOpts
rows === widgetData
where
jsonRoundTrip toOpts fromOpts =
withTestDb widgetData $ \conn ->
withSystemTempFile "beam-duckdb-prop-json-.json" $ \path h -> do
hClose h
runBeamDuckDB conn $
runCopyTo $
copyTableTo (_dbWidgets testDb) id (copyToJSONWith path toOpts)
void $ execute_ conn "DELETE FROM widgets"
runBeamDuckDB conn $
runCopyFrom $
copyTableFrom (_dbWidgets testDb) id (copyFromJSONWith path fromOpts)
queryAllWidgets conn
genJsonToOpts :: Hedgehog.Gen DuckDBJSONCopyToOptions
genJsonToOpts = do
comp <- Gen.maybe Gen.enumBounded
pure
defaultDuckDBJSONCopyToOptions
{ jsonCopyToCompression = comp,
jsonCopyToOverwriteOrIgnore = Just True
}
genJsonFromOpts :: Maybe JSONCompression -> Hedgehog.Gen DuckDBJSONCopyFromOptions
genJsonFromOpts comp = do
auto <- Gen.maybe Gen.bool
pure
defaultDuckDBJSONCopyFromOptions
{ jsonCopyFromAutoDetect = auto,
jsonCopyFromCompression = comp
}
withCopyTarget :: (Connection -> FilePath -> IO a) -> IO a
withCopyTarget action =
withTestDb widgetData $ \conn ->
withSystemTempFile "beam-duckdb-copy-.csv" $ \path h -> do
-- DuckDB's COPY TO opens the file itself; close our handle first
-- so it isn't held open while DuckDB writes.
hClose h
action conn path
withCopyFromSource :: Text -> (Connection -> FilePath -> IO a) -> IO a
withCopyFromSource csvContent action =
withTestDb [] $ \conn ->
withSystemTempFile "beam-duckdb-copy-from-.csv" $ \path h -> do
Text.IO.hPutStr h csvContent
hClose h
action conn path
readCsv :: FilePath -> IO (Text, [Text])
readCsv path = do
contents <- Text.IO.readFile path
case Text.lines contents of
[] -> fail ("expected at least a header line in " <> path)
(h : ds) -> pure (h, List.sort ds)
queryAllWidgets :: Connection -> IO [Widget]
queryAllWidgets conn =
runBeamDuckDB conn $
runSelectReturningList $
select $
orderBy_ (asc_ . _widgetId) $
all_ (_dbWidgets testDb)
widgetData :: [Widget]
widgetData =
[ Widget 1 "Widget" 9.99,
Widget 2 "Sprocket" 4.50,
Widget 3 "Cog" 1.25
]
data WidgetT f = Widget
{ _widgetId :: Columnar f Int32,
_widgetName :: Columnar f Text,
_widgetPrice :: Columnar f Double
}
deriving (Generic)
type Widget = WidgetT Identity
deriving instance Show Widget
deriving instance Eq Widget
instance Beamable WidgetT
instance Table WidgetT where
data PrimaryKey WidgetT f = WidgetId (Columnar f Int32)
deriving (Generic)
primaryKey = WidgetId . _widgetId
instance Beamable (PrimaryKey WidgetT)
newtype TestDB f = TestDB
{ _dbWidgets :: f (TableEntity WidgetT)
}
deriving (Generic, Database be)
testDb :: DatabaseSettings DuckDB TestDB
testDb =
defaultDbSettings
`withDbModification` dbModification
{ _dbWidgets =
modifyTableFields
tableModification
{ _widgetId = "id",
_widgetName = "name",
_widgetPrice = "price"
}
}
createTables :: Connection -> IO ()
createTables conn =
void $
execute_
conn
"CREATE TABLE widgets (\
\ id INTEGER PRIMARY KEY,\
\ name TEXT NOT NULL DEFAULT '',\
\ price DOUBLE NOT NULL DEFAULT 0\
\)"
seedData :: Connection -> [Widget] -> IO ()
seedData conn widgets =
runBeamDuckDB conn $
runInsert $
insert (_dbWidgets testDb) $
insertValues widgets
withTestDb ::
[Widget] ->
(Connection -> IO a) ->
IO a
withTestDb widgets action =
withConnection ":memory:" $ \conn -> do
createTables conn
seedData conn widgets
action conn