duckdb-simple-0.2.0.0: test/CoreRegressionTests.hs
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# OPTIONS_GHC -Wno-deprecations #-}
-- | Regression tests for result metadata, cursor state, and handle lifetime.
module CoreRegressionTests (coreRegressionTests) where
import Control.Exception (SomeException, throwIO, try)
import Control.Monad (forM_, replicateM_, void)
import Data.IORef (readIORef)
import Data.Int (Int64)
import Data.List (isInfixOf)
import Data.Text (Text)
import Database.DuckDB.FFI.Deprecated (c_duckdb_result_is_streaming)
import Database.DuckDB.Simple
import Database.DuckDB.Simple.FromField (FieldValue)
import Database.DuckDB.Simple.Internal (Statement (statementStream), StatementStream (statementStreamResult), StatementStreamState (..))
import System.Mem (performMajorGC)
import Test.Tasty (TestTree, testGroup)
import Test.Tasty.HUnit
-- | Exercise public operations against an in-memory database.
coreRegressionTests :: TestTree
coreRegressionTests =
testGroup
"core regressions"
[ testCase "stream metadata follows parameter types" $
withConnection ":memory:" $ \conn -> do
rows <- fold conn "SELECT ?" (Only (42 :: Int64)) [] collect
rows @?= [Only (42 :: Int64)]
, testCase "cursor uses the supported materialized execution API" $
withConnection ":memory:" $ \conn ->
withStatement conn "SELECT i FROM range(10000) t(i)" $ \stmt -> do
void (nextRow stmt :: IO (Maybe (Only Int64)))
state <- readIORef (statementStream stmt)
case state of
StatementStreamActive stream -> do
streaming <- c_duckdb_result_is_streaming (statementStreamResult stream)
assertBool "expected a materialized native result" (streaming == 0)
_ -> assertFailure "expected an active cursor"
, testCase "stream metadata preserves 64-bit values" $
withConnection ":memory:" $ \conn -> do
rows <- fold conn "SELECT coalesce(?, 1) FROM range(3)" (Only (5000000000 :: Int64)) [] collect
rows @?= replicate 3 (Only (5000000000 :: Int64))
, testCase "stream numeric to Text conversion uses result metadata" $
withConnection ":memory:" $ \conn -> do
rows <- fold conn "SELECT coalesce(?, 'x')" (Only (100 :: Int64)) [] collect
rows @?= [Only ("100" :: Text)]
, testCase "stream metadata follows schema rebinding" $
withConnection ":memory:" $ \conn -> do
void $ execute_ conn "CREATE TABLE rebind_probe(x VARCHAR)"
withStatement conn "SELECT x FROM rebind_probe" $ \stmt -> do
void $ execute_ conn "DROP TABLE rebind_probe"
void $ execute_ conn "CREATE TABLE rebind_probe(x BIGINT)"
void $ execute_ conn "INSERT INTO rebind_probe VALUES (5000000000)"
nextRow stmt >>= (@?= Just (Only (5000000000 :: Int64)))
, testCase "EOF remains exhausted" $
withConnection ":memory:" $ \conn ->
withStatement conn "SELECT 42" $ \stmt -> do
nextRow stmt >>= (@?= Just (Only (42 :: Int64)))
replicateM_ 3 $ nextRow stmt >>= (@?= (Nothing :: Maybe (Only Int64)))
, testCase "DML cursor executes only once" $
withConnection ":memory:" $ \conn -> do
void $ execute_ conn "CREATE TABLE once_probe(x INTEGER)"
withStatement conn "INSERT INTO once_probe VALUES (1)" $ \stmt ->
replicateM_ 3 $ void (nextRow stmt :: IO (Maybe (Only Int64)))
query_ conn "SELECT count(*) FROM once_probe" >>= (@?= [Only (1 :: Int64)])
, testCase "rebinding resets an exhausted cursor" $
withConnection ":memory:" $ \conn ->
withStatement conn "SELECT ?::BIGINT" $ \stmt -> do
bind stmt [toField (1 :: Int64)]
nextRow stmt >>= (@?= Just (Only (1 :: Int64)))
nextRow stmt >>= (@?= (Nothing :: Maybe (Only Int64)))
bind stmt [toField (2 :: Int64)]
nextRow stmt >>= (@?= Just (Only (2 :: Int64)))
, testCase "clear bindings invalidates an active cursor" $
withConnection ":memory:" $ \conn ->
withStatement conn "SELECT ?::BIGINT FROM range(3)" $ \stmt -> do
bind stmt [toField (7 :: Int64)]
void (nextRow stmt :: IO (Maybe (Only Int64)))
clearStatementBindings stmt
assertThrows (nextRow stmt :: IO (Maybe (Only Int64)))
, testCase "fetch errors are not EOF" $
withConnectionWithConfig ":memory:" [("threads", "1")] $ \conn ->
assertThrows (fold_ conn "SELECT CASE WHEN i = 5000 THEN error('late failure') ELSE i END FROM range(10000) t(i)" (0 :: Int64) (\acc (Only n) -> pure (acc + n)))
, testCase "composite cursors preserve values after chunk cleanup" $
withConnectionWithConfig ":memory:" [("threads", "1")] $ \conn ->
forM_
[ "SELECT {'n': i, 'text': 'λ' || i::VARCHAR} FROM range(5000) t(i)"
, "SELECT CASE WHEN i % 2 = 0 THEN union_value(n := i)::UNION(n BIGINT, text VARCHAR) ELSE union_value(text := i::VARCHAR)::UNION(n BIGINT, text VARCHAR) END FROM range(5000) t(i)"
, "SELECT CASE WHEN i % 3 = 0 THEN NULL ELSE {'n': i, 'list': [i, NULL], 'union': union_value(v := i)} END FROM range(5000) t(i)"
]
$ \sql -> do
eager <- query_ conn sql :: IO [Only FieldValue]
streamed <- reverse <$> fold_ conn sql [] (\acc row -> pure (row : acc))
streamed @?= eager
, testCase "composite cursor failures release the result" $
withConnectionWithConfig ":memory:" [("threads", "1")] $ \conn -> do
let sql = "SELECT {'n': i, 'values': [i, NULL]} FROM range(5000) t(i)"
assertThrows (fold_ conn sql () (\() (Only (_ :: Int64)) -> pure ()))
assertThrows $ fold_ conn sql (0 :: Int) $ \n (Only (_ :: FieldValue)) ->
if n == 2050 then throwIO (userError "composite fold failure") else pure (n + 1)
query_ conn "SELECT 42" >>= (@?= [Only (42 :: Int64)])
, testCase "closed connection rejects active cursor reads" $ do
conn <- open ":memory:"
stmt <- openStatement conn "SELECT i FROM range(3) t(i)"
void (nextRow stmt :: IO (Maybe (Only Int64)))
close conn
assertThrows (nextRow stmt :: IO (Maybe (Only Int64)))
closeStatement stmt
, testCase "NUL in SQL is rejected" $
withConnection ":memory:" $ \conn ->
assertThrows (query_ conn "SELECT 1\0 + 2" :: IO [Only Int64])
, testCase "NUL in database path is rejected" $
assertThrows (withConnection ":memory:\0suffix" (const (pure ())))
, testCase "NUL in configuration value is rejected" $
assertThrows (withConnectionWithConfig ":memory:" [("threads", "1\0suffix")] (const (pure ())))
, testCase "NUL in parameter name is rejected" $
withConnection ":memory:" $ \conn ->
withStatement conn "SELECT $value" $ \stmt ->
assertThrows (namedParameterIndex stmt "value\0suffix")
, testCase "duplicate named bindings are rejected" $
withConnection ":memory:" $ \conn ->
withStatement conn "SELECT $a + $b" $ \stmt -> do
result <- try (bindNamed stmt ["a" := (1 :: Int64), "$a" := (2 :: Int64)])
case result of
Left (_ :: FormatError) -> pure ()
Right () -> assertFailure "expected duplicate binding error before execution"
, testCase "transaction preserves the original exception if rollback fails" $
withConnection ":memory:" $ \conn -> do
result <- try $ withTransaction conn $ do
void $ execute_ conn "ROLLBACK"
throwIO (userError "original transaction failure")
case result of
Left (err :: IOError) -> assertBool "original exception" ("original transaction failure" `isInfixOf` show err)
Right () -> assertFailure "expected transaction failure"
, testCase "GC during the last connection use keeps native owners alive" $ do
-- A bracket cleanup would retain conn and hide early finalization.
conn <- open ":memory:"
createFunction conn "gc_identity" (\(n :: Int64) -> performMajorGC >> pure n)
rows <- query_ conn "SELECT gc_identity(i) FROM range(10) t(i)"
rows @?= map Only ([0 .. 9] :: [Int64])
]
where
collect acc row = pure (acc <> [row])
-- | Require an exception without constraining its concrete type.
assertThrows :: IO a -> Assertion
assertThrows action = do
result <- try (void action)
case result of
Left (_ :: SomeException) -> pure ()
Right () -> assertFailure "expected an exception"