ppad-lmdb-0.1.0: test/Main.hs
{-# LANGUAGE OverloadedStrings #-}
module Main where
import qualified Control.Exception as E
import Control.Monad (forM_)
import qualified Data.ByteString as BS
import qualified Data.ByteString.Char8 as BSC
import qualified Data.Map.Strict as Map
import qualified Database.LMDB as L
import System.Directory
( createDirectory
, doesDirectoryExist
, getTemporaryDirectory
, removeDirectoryRecursive
)
import System.FilePath ((</>))
import Test.Tasty
import Test.Tasty.HUnit
import Test.Tasty.QuickCheck as QC
main :: IO ()
main = defaultMain $ testGroup "ppad-lmdb"
[ unitTests
, propTests
]
-- helpers --------------------------------------------------------------------
-- | Run an action against a fresh temporary db directory. The
-- directory is created (clean) before the action and removed after.
withTmpEnv :: L.EnvFlags -> (L.Env -> IO a) -> IO a
withTmpEnv envFlags k = do
tmpRoot <- getTemporaryDirectory
let dir = tmpRoot </> "ppad-lmdb-test"
exists <- doesDirectoryExist dir
if exists then removeDirectoryRecursive dir else pure ()
createDirectory dir
result <- L.withEnv dir envFlags k
removeDirectoryRecursive dir
pure result
-- shared bigger map for tests
flags :: L.EnvFlags
flags = L.defaultEnvFlags
{ L.envMapSize = 64 * 1024 * 1024
, L.envMaxDbs = 4
}
writeKV
:: L.Env -> [(BS.ByteString, BS.ByteString)] -> IO ()
writeKV env kvs = L.withWriteTxn env $ \txn -> do
dbi <- L.openDbi txn Nothing True
forM_ kvs $ \(k, v) -> L.put txn dbi k v
-- unit tests -----------------------------------------------------------------
unitTests :: TestTree
unitTests = testGroup "unit"
[ testCase "put then get roundtrip" $
withTmpEnv flags $ \env -> do
writeKV env [("hello", "world")]
v <- L.withReadTxn env $ \txn -> do
dbi <- L.openDbi txn Nothing False
L.get txn dbi "hello"
v @?= Just "world"
, testCase "get on missing key returns Nothing" $
withTmpEnv flags $ \env -> do
writeKV env [("a", "1")]
v <- L.withReadTxn env $ \txn -> do
dbi <- L.openDbi txn Nothing False
L.get txn dbi "absent"
v @?= Nothing
, testCase "del returns True only when key existed" $
withTmpEnv flags $ \env -> do
writeKV env [("a", "1"), ("b", "2")]
(existed, repeated, missing) <- L.withWriteTxn env $ \txn -> do
dbi <- L.openDbi txn Nothing False
e <- L.del txn dbi "a"
e2 <- L.del txn dbi "a"
m <- L.del txn dbi "never-existed"
pure (e, e2, m)
existed @?= True
repeated @?= False
missing @?= False
, testCase "write txn aborted on exception" $
withTmpEnv flags $ \env -> do
-- attempt that throws after a write
let attempt = L.withWriteTxn env $ \txn -> do
dbi <- L.openDbi txn Nothing True
L.put txn dbi "k" "v"
E.throwIO (userError "boom")
r <- E.try attempt :: IO (Either IOError ())
case r of
Left _ -> pure ()
Right _ -> assertFailure "expected the user error"
-- key must not be visible to a fresh read txn
v <- L.withReadTxn env $ \txn -> do
dbi <- L.openDbi txn Nothing True
L.get txn dbi "k"
v @?= Nothing
, testCase "cursor walks keys in ascending order" $
withTmpEnv flags $ \env -> do
let kvs = [ (BSC.pack [c], BSC.pack [c])
| c <- "abcdefghij" ]
writeKV env kvs
seen <- L.withReadTxn env $ \txn -> do
dbi <- L.openDbi txn Nothing False
L.withCursor txn dbi (cursorWalk [])
seen @?= kvs
, testCase "cursorSeek lands on first >= key" $
withTmpEnv flags $ \env -> do
writeKV env [ ("a","1"), ("c","3"), ("e","5") ]
r <- L.withReadTxn env $ \txn -> do
dbi <- L.openDbi txn Nothing False
L.withCursor txn dbi $ \cur ->
L.cursorSeek cur "b"
r @?= Just ("c", "3")
]
-- walk a cursor, accumulating pairs from current position to end
cursorWalk
:: [(BS.ByteString, BS.ByteString)]
-> L.Cursor s
-> IO [(BS.ByteString, BS.ByteString)]
cursorWalk acc cur = do
step <- L.cursorFirst cur
go (reverse acc) step
where
go ys Nothing = pure (reverse ys)
go ys (Just kv) = do
next <- L.cursorNext cur
go (kv : ys) next
-- properties -----------------------------------------------------------------
newtype Key = Key BS.ByteString deriving Show
newtype Val = Val BS.ByteString deriving Show
-- LMDB rejects empty keys; restrict to non-empty bounded strings.
instance Arbitrary Key where
arbitrary = do
n <- QC.chooseInt (1, 32)
Key . BS.pack <$> QC.vectorOf n QC.arbitrary
instance Arbitrary Val where
arbitrary = do
n <- QC.chooseInt (0, 64)
Val . BS.pack <$> QC.vectorOf n QC.arbitrary
propTests :: TestTree
propTests = testGroup "props"
[ QC.testProperty "put / get roundtrip" $ \(Key k) (Val v) ->
QC.ioProperty $ withTmpEnv flags $ \env -> do
writeKV env [(k, v)]
got <- L.withReadTxn env $ \txn -> do
dbi <- L.openDbi txn Nothing False
L.get txn dbi k
pure (got == Just v)
, QC.testProperty "cursor enumerates Map.toAscList" $ \kvs0 ->
let kvs = uniqueKeys [(k, v) | (Key k, Val v) <- kvs0]
in QC.ioProperty $ withTmpEnv flags $ \env -> do
writeKV env kvs
seen <- L.withReadTxn env $ \txn -> do
dbi <- L.openDbi txn Nothing False
L.withCursor txn dbi (cursorWalk [])
pure (seen == Map.toAscList (Map.fromList kvs))
]
-- keep only the last write for each key, matching LMDB's overwrite
-- semantics under MDB_NOOVERWRITE-not-set
uniqueKeys
:: [(BS.ByteString, BS.ByteString)]
-> [(BS.ByteString, BS.ByteString)]
uniqueKeys = Map.toList . Map.fromList