beam-postgres-0.6.2.0: test/Database/Beam/Postgres/Test/Copy.hs
{-# LANGUAGE DeriveAnyClass #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE StandaloneDeriving #-}
{-# LANGUAGE TypeFamilies #-}
module Database.Beam.Postgres.Test.Copy (tests) where
import Control.Monad (void)
import Control.Monad.IO.Class (liftIO)
import Data.ByteString (ByteString)
import qualified Data.ByteString as BS
import Data.IORef (modifyIORef', newIORef, readIORef, writeIORef)
import Data.Int (Int32)
import Data.List (sort)
import Data.Text (Text)
import qualified Data.Text as T
import Database.Beam
import Database.Beam.Backend.SQL.BeamExtensions
( MonadBeamCopyFrom (..),
MonadBeamCopyFromStream (..),
MonadBeamCopyTo (..),
MonadBeamCopyToStream (..),
copySelectTo,
copySelectToStream,
copyTableFrom,
copyTableFromStream,
copyTableTo,
copyTableToStream,
)
import Database.Beam.Postgres
import Database.Beam.Postgres.Test (withTestPostgres)
import Database.PostgreSQL.Simple (Connection, execute_)
import Hedgehog ((===))
import qualified Hedgehog
import qualified Hedgehog.Gen as Gen
import qualified Hedgehog.Range as Range
import Test.Tasty (TestTree, testGroup)
import Test.Tasty.HUnit (Assertion, assertBool, assertEqual, testCase)
tests :: IO ByteString -> TestTree
tests getConn =
testGroup
"COPY statements"
[ testGroup
"COPY ... TO / ... FROM round-trip"
[ testRoundtripCsvAllColumns getConn,
testRoundtripCsvMultiColumnProjection getConn,
testRoundtripCsvSingleColumnProjection getConn,
testRoundtripCsvFromSelect getConn
],
testGroup
"options"
[ testCustomDelimiter getConn,
testNoHeader getConn,
testTextFormat getConn
],
testGroup
"streaming COPY"
[ testStreamRoundtripCsv getConn,
testStreamFromSelect getConn
],
testGroup
"property-based round-trip"
[ testPropCsvRoundtrip getConn,
testPropTextRoundtrip getConn
]
]
-- | The full table is exported to a CSV, the table is truncated, and the CSV
-- is re-imported. The final rows must match the original ones.
testRoundtripCsvAllColumns :: IO ByteString -> TestTree
testRoundtripCsvAllColumns getConn =
testCopy getConn "copy_all_columns_csv" "/tmp/copy_all_columns.csv" $ \conn path -> do
seedWidgets conn widgetData
runBeamPostgres conn $ do
runCopyTo $ copyTableTo (_dbWidgets testDb) id (copyToCSV path)
truncateWidgets conn
runBeamPostgres conn $ do
runCopyFrom $ copyTableFrom (_dbWidgets testDb) id (copyFromCSV path)
rows <- queryAllWidgets conn
assertEqual "round-tripped rows match seed" widgetData rows
-- | Project a subset of columns; the unprojected column gets its DEFAULT on
-- import.
testRoundtripCsvMultiColumnProjection :: IO ByteString -> TestTree
testRoundtripCsvMultiColumnProjection getConn =
testCopy getConn "copy_multi_column_csv" "/tmp/copy_multi_column.csv" $ \conn path -> do
seedWidgets conn widgetData
runBeamPostgres conn $ do
runCopyTo $
copyTableTo
(_dbWidgets testDb)
(\w -> (_widgetId w, _widgetName w))
(copyToCSV path)
truncateWidgets conn
runBeamPostgres conn $ do
runCopyFrom $
copyTableFrom
(_dbWidgets testDb)
(\w -> (_widgetId w, _widgetName w))
(copyFromCSV path)
rows <- queryAllWidgets conn
-- 'price' was not in the projection, so it gets its DEFAULT (0).
let expected = [w {_widgetPrice = 0} | w <- widgetData]
assertEqual "id+name round-tripped, price defaulted" expected rows
-- | Single-column projection.
testRoundtripCsvSingleColumnProjection :: IO ByteString -> TestTree
testRoundtripCsvSingleColumnProjection getConn =
testCopy getConn "copy_single_column_csv" "/tmp/copy_single_column.csv" $ \conn path -> do
seedWidgets conn widgetData
runBeamPostgres conn $ do
runCopyTo $ copyTableTo (_dbWidgets testDb) _widgetId (copyToCSV path)
truncateWidgets conn
runBeamPostgres conn $ do
runCopyFrom $ copyTableFrom (_dbWidgets testDb) _widgetId (copyFromCSV path)
rows <- queryAllWidgets conn
let expected = [w {_widgetName = "", _widgetPrice = 0} | w <- widgetData]
assertEqual "id-only round-tripped, name+price defaulted" expected rows
-- | Use copySelectTo with a filtered query, then COPY FROM the result back
-- into the table.
testRoundtripCsvFromSelect :: IO ByteString -> TestTree
testRoundtripCsvFromSelect getConn =
testCopy getConn "copy_select_csv" "/tmp/copy_select.csv" $ \conn path -> do
seedWidgets conn widgetData
runBeamPostgres conn $ do
runCopyTo $
copySelectTo
( select $ do
w <- all_ (_dbWidgets testDb)
guard_ (_widgetPrice w >. val_ 4.0)
pure w
)
(copyToCSV path)
truncateWidgets conn
runBeamPostgres conn $ do
runCopyFrom $ copyTableFrom (_dbWidgets testDb) id (copyFromCSV path)
rows <- queryAllWidgets conn
-- Only Widget (9.99) and Sprocket (4.5) have price > 4.0; Cog is excluded.
let expected = sort [w | w <- widgetData, _widgetPrice w > 4.0]
assertEqual "select-filtered round-trip" expected rows
-- | Use a custom delimiter on both sides of the round-trip.
testCustomDelimiter :: IO ByteString -> TestTree
testCustomDelimiter getConn =
testCopy getConn "copy_custom_delim_csv" "/tmp/copy_custom_delim.csv" $ \conn path -> do
seedWidgets conn widgetData
let toOpts = copyToCSVWith path defaultPgCSVCopyToOptions {pgCsvCopyToDelimiter = Just '|'}
fromOpts = copyFromCSVWith path defaultPgCSVCopyFromOptions {pgCsvCopyFromDelimiter = Just '|'}
runBeamPostgres conn $ do
runCopyTo $ copyTableTo (_dbWidgets testDb) id toOpts
truncateWidgets conn
runBeamPostgres conn $ do
runCopyFrom $ copyTableFrom (_dbWidgets testDb) id fromOpts
rows <- queryAllWidgets conn
assertEqual "round-trip with '|' delimiter" widgetData rows
-- | Toggle HEADER off on TO and on on FROM; round-trip should still work
-- (as long as both sides agree).
testNoHeader :: IO ByteString -> TestTree
testNoHeader getConn =
testCopy getConn "copy_no_header_csv" "/tmp/copy_no_header.csv" $ \conn path -> do
seedWidgets conn widgetData
let toOpts = copyToCSVWith path defaultPgCSVCopyToOptions {pgCsvCopyToHeader = Just False}
fromOpts = copyFromCSVWith path defaultPgCSVCopyFromOptions {pgCsvCopyFromHeader = Just False}
runBeamPostgres conn $ do
runCopyTo $ copyTableTo (_dbWidgets testDb) id toOpts
truncateWidgets conn
runBeamPostgres conn $ do
runCopyFrom $ copyTableFrom (_dbWidgets testDb) id fromOpts
rows <- queryAllWidgets conn
assertEqual "round-trip without header line" widgetData rows
-- | The 'text' format with custom delimiter, round-tripped.
testTextFormat :: IO ByteString -> TestTree
testTextFormat getConn =
testCopy getConn "copy_text_format" "/tmp/copy_text.txt" $ \conn path -> do
seedWidgets conn widgetData
let toOpts = copyToTextWith path defaultPgTextCopyToOptions {pgTextCopyToDelimiter = Just '\t'}
fromOpts = copyFromTextWith path defaultPgTextCopyFromOptions {pgTextCopyFromDelimiter = Just '\t'}
runBeamPostgres conn $ do
runCopyTo $ copyTableTo (_dbWidgets testDb) id toOpts
truncateWidgets conn
runBeamPostgres conn $ do
runCopyFrom $ copyTableFrom (_dbWidgets testDb) id fromOpts
rows <- queryAllWidgets conn
assertEqual "round-trip via text format" widgetData rows
-- | Round-trip widgets through streaming @COPY ... TO STDOUT@ then
-- @COPY ... FROM STDIN@. The bytes pass through the client connection only;
-- no server-side file is touched.
testStreamRoundtripCsv :: IO ByteString -> TestTree
testStreamRoundtripCsv getConn =
testCopy getConn "stream_roundtrip_csv" "" $ \conn _ -> do
seedWidgets conn widgetData
-- Drain the stream into an IORef.
chunksRef <- newIORef []
runBeamPostgres conn $
runCopyToStream
(copyTableToStream (_dbWidgets testDb) id copyToCSVStream)
(\chunk -> modifyIORef' chunksRef (chunk :))
payload <- BS.concat . reverse <$> readIORef chunksRef
assertBool "stream produced non-empty payload" (not (BS.null payload))
-- Replay the captured bytes back into the table.
truncateWidgets conn
sourceRef <- newIORef (Just payload)
runBeamPostgres conn $
runCopyFromStream
(copyTableFromStream (_dbWidgets testDb) id copyFromCSVStream)
( do
mchunk <- readIORef sourceRef
writeIORef sourceRef Nothing
pure mchunk
)
rows <- queryAllWidgets conn
assertEqual "round-tripped rows match seed" widgetData rows
-- | @COPY (SELECT ...) TO STDOUT@ via 'copySelectToStream'.
testStreamFromSelect :: IO ByteString -> TestTree
testStreamFromSelect getConn =
testCopy getConn "stream_from_select" "" $ \conn _ -> do
seedWidgets conn widgetData
chunksRef <- newIORef []
runBeamPostgres conn $
runCopyToStream
( copySelectToStream
( select $ do
w <- all_ (_dbWidgets testDb)
guard_ (_widgetPrice w >. val_ 4.0)
pure w
)
copyToCSVStream
)
(\chunk -> modifyIORef' chunksRef (chunk :))
payload <- BS.concat . reverse <$> readIORef chunksRef
-- The payload should mention the two widgets with price > 4.0 and not
-- the one priced 1.25.
assertBool "Widget present" ("Widget" `BS.isInfixOf` payload)
assertBool "Sprocket present" ("Sprocket" `BS.isInfixOf` payload)
assertBool "Cog absent" (not ("Cog" `BS.isInfixOf` payload))
testPropCsvRoundtrip :: IO ByteString -> TestTree
testPropCsvRoundtrip getConn =
testCopy getConn "prop_csv_roundtrip" "/tmp/prop_csv.csv" $ \conn path -> do
passes <-
Hedgehog.check . Hedgehog.property $ do
toOpts <- Hedgehog.forAll genCsvToOpts
let fromOpts = matchedCsvFromOpts toOpts
rows <-
liftIO $
roundtripWidgets conn $ \c -> do
runBeamPostgres c $
runCopyTo $
copyTableTo (_dbWidgets testDb) id (copyToCSVWith path toOpts)
truncateWidgets c
runBeamPostgres c $
runCopyFrom $
copyTableFrom (_dbWidgets testDb) id (copyFromCSVWith path fromOpts)
rows === widgetData
assertBool "Hedgehog property failed" passes
where
genCsvToOpts = do
delim <- Gen.maybe genDelimiterChar
hdr <- Gen.maybe Gen.bool
quote <- Gen.maybe genQuoteChar
escape <- Gen.maybe genEscapeChar
nullStr <- Gen.maybe genNullStr
pure
defaultPgCSVCopyToOptions
{ pgCsvCopyToDelimiter = delim,
pgCsvCopyToHeader = hdr,
pgCsvCopyToQuote = quote,
pgCsvCopyToEscape = escape,
pgCsvCopyToNullStr = nullStr
}
matchedCsvFromOpts :: PgCSVCopyToOptions -> PgCSVCopyFromOptions
matchedCsvFromOpts o =
defaultPgCSVCopyFromOptions
{ pgCsvCopyFromDelimiter = pgCsvCopyToDelimiter o,
pgCsvCopyFromHeader = pgCsvCopyToHeader o,
pgCsvCopyFromQuote = pgCsvCopyToQuote o,
pgCsvCopyFromEscape = pgCsvCopyToEscape o,
pgCsvCopyFromNullStr = pgCsvCopyToNullStr o
}
testPropTextRoundtrip :: IO ByteString -> TestTree
testPropTextRoundtrip getConn =
testCopy getConn "prop_text_roundtrip" "/tmp/prop_text.txt" $ \conn path -> do
passes <-
Hedgehog.check . Hedgehog.property $ do
toOpts <- Hedgehog.forAll genTextToOpts
let fromOpts = matchedTextFromOpts toOpts
rows <-
liftIO $
roundtripWidgets conn $ \c -> do
runBeamPostgres c $
runCopyTo $
copyTableTo (_dbWidgets testDb) id (copyToTextWith path toOpts)
truncateWidgets c
runBeamPostgres c $
runCopyFrom $
copyTableFrom (_dbWidgets testDb) id (copyFromTextWith path fromOpts)
rows === widgetData
assertBool "Hedgehog property failed" passes
where
genTextToOpts = do
delim <- Gen.maybe genDelimiterChar
hdr <- Gen.maybe Gen.bool
-- Avoid generating a NULL string that contains the chosen delimiter.
nullStr <- Gen.maybe (Gen.filter (not . T.any (== ',')) genNullStr)
pure
defaultPgTextCopyToOptions
{ pgTextCopyToDelimiter = delim,
pgTextCopyToHeader = hdr,
pgTextCopyToNullStr = nullStr
}
-- \| Mirror of 'matchedCsvFromOpts' for the @text@ format. 'pgTextCopyFromFreeze'
-- is intentionally left at @Nothing@: PostgreSQL only accepts @FREEZE@ when
-- the target table has not been touched in the current (sub)transaction, but
-- our property test truncates before the FROM, which violates that
-- precondition.
matchedTextFromOpts :: PgTextCopyToOptions -> PgTextCopyFromOptions
matchedTextFromOpts o =
defaultPgTextCopyFromOptions
{ pgTextCopyFromDelimiter = pgTextCopyToDelimiter o,
pgTextCopyFromHeader = pgTextCopyToHeader o,
pgTextCopyFromNullStr = pgTextCopyToNullStr o
}
-- Single-byte delimiters that never appear in 'widgetData'. Avoiding @.@
-- matters for the @text@ format because the data contains floating-point
-- prices like @9.99@.
genDelimiterChar :: Hedgehog.Gen Char
genDelimiterChar = Gen.element [',', ';', '|', '\t']
genQuoteChar :: Hedgehog.Gen Char
genQuoteChar = Gen.element ['"', '\'']
genEscapeChar :: Hedgehog.Gen Char
genEscapeChar = Gen.element ['"', '\'', '\\']
genNullStr :: Hedgehog.Gen Text
genNullStr = Gen.element ["NULL", "~~~"]
roundtripWidgets :: Connection -> (Connection -> IO ()) -> IO [Widget]
roundtripWidgets conn action = do
truncateWidgets conn
seedWidgets conn widgetData
action conn
queryAllWidgets conn
testCopy ::
IO ByteString ->
String ->
-- | Server-side file path inside the Postgres container.
FilePath ->
(Connection -> FilePath -> Assertion) ->
TestTree
testCopy getConn name path action = testCase name $
withTestPostgres name getConn $ \conn -> do
createWidgetsTable conn
action conn path
queryAllWidgets :: Connection -> IO [Widget]
queryAllWidgets conn =
runBeamPostgres conn $
runSelectReturningList $
select $
orderBy_ (asc_ . _widgetId) $
all_ (_dbWidgets testDb)
createWidgetsTable :: Connection -> IO ()
createWidgetsTable conn =
void $
execute_
conn
"CREATE TABLE widgets (\
\ id INTEGER PRIMARY KEY,\
\ name TEXT NOT NULL DEFAULT '',\
\ price DOUBLE PRECISION NOT NULL DEFAULT 0\
\)"
truncateWidgets :: Connection -> IO ()
truncateWidgets conn = void $ execute_ conn "TRUNCATE TABLE widgets"
seedWidgets :: Connection -> [Widget] -> IO ()
seedWidgets conn widgets =
runBeamPostgres conn $
runInsert $
insert (_dbWidgets testDb) (insertValues widgets)
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
deriving instance Ord 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 Postgres TestDB
testDb =
defaultDbSettings
`withDbModification` dbModification
{ _dbWidgets =
modifyTableFields
tableModification
{ _widgetId = "id",
_widgetName = "name",
_widgetPrice = "price"
}
}