packages feed

duckdb-ffi-1.4.1.2: test/CastFunctionsTest.hs

{-# LANGUAGE BlockArguments #-}
{-# LANGUAGE ForeignFunctionInterface #-}
{-# LANGUAGE NamedFieldPuns #-}

module CastFunctionsTest (tests) where

import Control.Exception (bracket)
import Control.Monad (when)
import Data.IORef (IORef, newIORef, readIORef, writeIORef)
import Data.Int (Int32)
import Data.List (isInfixOf)
import Database.DuckDB.FFI
import Database.DuckDB.FFI.Deprecated
import Foreign.C.String (peekCString, withCString, withCStringLen)
import Foreign.C.Types (CBool (..))
import Foreign.Marshal.Alloc (alloca)
import Foreign.Ptr (Ptr, castPtr, freeHaskellFunPtr, nullFunPtr, nullPtr)
import Foreign.StablePtr (StablePtr, castPtrToStablePtr, castStablePtrToPtr, deRefStablePtr, freeStablePtr, newStablePtr)
import Foreign.Storable (peekElemOff, poke)
import Test.Tasty (TestTree, testGroup)
import Test.Tasty.HUnit (assertBool, testCase, (@?=))
import Utils (withConnection, withDatabase, withLogicalType, withResult)

data CastHarness = CastHarness
    { chLastMode :: IO DuckDBCastMode
    , chExtraSeen :: IO Bool
    }

tests :: TestTree
tests =
    testGroup
        "Cast Functions"
        [ castFunctionOverridesBuiltin
        , castFunctionReportsErrors
        , castFunctionTryMode
        ]

castFunctionOverridesBuiltin :: TestTree
castFunctionOverridesBuiltin =
    testCase "custom cast overrides builtin conversion" $
        withDatabase \db ->
            withConnection db \conn ->
                withTestCast conn \_ -> do
                    withResult conn "SELECT CAST(v AS VARCHAR) FROM (VALUES (1), (5)) AS t(v)" \resPtr -> do
                        fetchString resPtr 0 0 >>= (@?= "value: 1")
                        fetchString resPtr 0 1 >>= (@?= "value: 5")
                        errPtr <- c_duckdb_result_error resPtr
                        errPtr @?= nullPtr

castFunctionReportsErrors :: TestTree
castFunctionReportsErrors =
    testCase "cast failure surfaces error message" $
        withDatabase \db ->
            withConnection db \conn ->
                withTestCast conn \_ ->
                    withCString "SELECT CAST(v AS VARCHAR) FROM (VALUES (-7)) AS t(v)" \sql ->
                        alloca \resPtr -> do
                            state <- c_duckdb_query conn sql resPtr
                            state @?= DuckDBError
                            errPtr <- c_duckdb_result_error resPtr
                            assertBool "expected error pointer" (errPtr /= nullPtr)
                            errMsg <- peekCString errPtr
                            assertBool "error message should mention negative" ("negative" `isInfixOf` errMsg)
                            c_duckdb_destroy_result resPtr

castFunctionTryMode :: TestTree
castFunctionTryMode =
    testCase "try_cast produces null rows and reports try mode" $
        withDatabase \db ->
            withConnection db \conn ->
                withTestCast conn \CastHarness{chLastMode, chExtraSeen} -> do
                    withResult conn "SELECT TRY_CAST(v AS VARCHAR) FROM (VALUES (2), (-3)) AS t(v)" \resPtr -> do
                        fetchString resPtr 0 0 >>= (@?= "value: 2")
                        c_duckdb_value_is_null resPtr 0 1 >>= (@?= CBool 1)
                        errPtr <- c_duckdb_result_error resPtr
                        errPtr @?= nullPtr
                    chLastMode >>= (@?= DuckDBCastTry)
                    chExtraSeen >>= (@?= True)

-- Harness ------------------------------------------------------------------

withTestCast :: DuckDBConnection -> (CastHarness -> IO a) -> IO a
withTestCast conn action = do
    modeRef <- newIORef DuckDBCastNormal
    extraSeenRef <- newIORef False
    prefixStable <- newStablePtr ("value: " :: String)
    castFunPtr <- mkCastFun (castCallback modeRef extraSeenRef prefixStable)
    result <-
        withCastFunction \castFun ->
            withLogicalType (c_duckdb_create_logical_type DuckDBTypeInteger) \sourceType ->
                withLogicalType (c_duckdb_create_logical_type DuckDBTypeVarchar) \targetType -> do
                    c_duckdb_cast_function_set_source_type castFun sourceType
                    c_duckdb_cast_function_set_target_type castFun targetType
                    c_duckdb_cast_function_set_implicit_cast_cost castFun 0
                    c_duckdb_cast_function_set_extra_info castFun (castStablePtrToPtr prefixStable) nullFunPtr
                    c_duckdb_cast_function_set_function castFun castFunPtr
                    c_duckdb_register_cast_function conn castFun >>= (@?= DuckDBSuccess)
                    action
                        CastHarness
                            { chLastMode = readIORef modeRef
                            , chExtraSeen = readIORef extraSeenRef
                            }
    freeHaskellFunPtr castFunPtr
    freeStablePtr prefixStable
    pure result

castCallback :: IORef DuckDBCastMode -> IORef Bool -> StablePtr String -> DuckDBFunctionInfo -> DuckDBIdx -> DuckDBVector -> DuckDBVector -> IO CBool
castCallback modeRef extraSeenRef prefixStable info count inputVec outputVec = do
    actualPtr <- c_duckdb_cast_function_get_extra_info info
    when (actualPtr == castStablePtrToPtr prefixStable) $ writeIORef extraSeenRef True
    prefix <-
        if actualPtr == nullPtr
            then pure "value: "
            else deRefStablePtr (castPtrToStablePtr actualPtr)
    mode <- c_duckdb_cast_function_get_cast_mode info
    writeIORef modeRef mode
    inputData <- c_duckdb_vector_get_data inputVec
    c_duckdb_vector_ensure_validity_writable outputVec
    let inPtr = castPtr inputData :: Ptr Int32
        rowCount = fromIntegral count :: Int
    success <- processRows prefix mode inPtr outputVec 0 rowCount
    pure (if success then CBool 1 else CBool 0)
  where
    processRows :: String -> DuckDBCastMode -> Ptr Int32 -> DuckDBVector -> Int -> Int -> IO Bool
    processRows prefix mode inPtr outVec idx total
        | idx >= total = pure True
        | otherwise = do
            val <- peekElemOff inPtr idx
            if val < 0
                then withCString "negative values not allowed" \errMsg ->
                    if mode == DuckDBCastTry
                        then do
                            c_duckdb_cast_function_set_row_error info errMsg (fromIntegral idx) outVec
                            processRows prefix mode inPtr outVec (idx + 1) total
                        else do
                            c_duckdb_cast_function_set_error info errMsg
                            pure False
                else do
                    let rendered = prefix ++ show val
                    withCStringLen rendered \(cStr, len) ->
                        c_duckdb_vector_assign_string_element_len outVec (fromIntegral idx) cStr (fromIntegral len)
                    processRows prefix mode inPtr outVec (idx + 1) total

-- Helpers ------------------------------------------------------------------

withCastFunction :: (DuckDBCastFunction -> IO a) -> IO a
withCastFunction = bracket c_duckdb_create_cast_function destroy
  where
    destroy cf = alloca \ptr -> poke ptr cf >> c_duckdb_destroy_cast_function ptr

fetchString :: Ptr DuckDBResult -> DuckDBIdx -> DuckDBIdx -> IO String
fetchString resPtr col row = do
    cStr <- c_duckdb_value_varchar resPtr col row
    value <- peekCString cStr
    c_duckdb_free (castPtr cStr)
    pure value

-- Wrapper constructors ------------------------------------------------------

foreign import ccall "wrapper"
    mkCastFun :: (DuckDBFunctionInfo -> DuckDBIdx -> DuckDBVector -> DuckDBVector -> IO CBool) -> IO DuckDBCastFunctionFun