packages feed

mysql-haskell 1.3.1 → 1.3.2

raw patch · 7 files changed

+404/−82 lines, 7 filesdep −memorydep ~cryptonPVP ok

version bump matches the API change (PVP)

Dependencies removed: memory

Dependency ranges changed: crypton

API changes (from Hackage documentation)

+ Database.MySQL.Base: ExtraResultSets :: ExtraResultSets
+ Database.MySQL.Base: data ExtraResultSets
+ Database.MySQL.Connection: ExtraResultSets :: ExtraResultSets
+ Database.MySQL.Connection: data ExtraResultSets
+ Database.MySQL.Connection: instance GHC.Exception.Type.Exception Database.MySQL.Connection.ExtraResultSets
+ Database.MySQL.Connection: instance GHC.Show.Show Database.MySQL.Connection.ExtraResultSets

Files

ChangeLog.md view
@@ -1,5 +1,32 @@ # Revision history for mysql-haskell +## 1.3.2 -- 2026.10.07++Requires crypton 2.0 or newer, the first crypton with the timing side-channel+fixes that TLS connections reach; a project held to crypton 1.x keeps+resolving to 1.3.1. The only API addition is the `ExtraResultSets` exception.+The query functions now return an empty result for an INSERT, UPDATE or DELETE+instead of hanging. `execute_`, `executeStmt` and the query functions read the+whole reply of a CALL or a multi-statement query, so they no longer leave+results behind for the next command; `executeMany` and `executeMany_` are+unchanged (#93).++Errors change only for statements that produce several results, or rows where+`execute_` and `executeStmt` expect none. Those two raise `ExtraResultSets` for+a result set (1.3.1 threw `UnexpectedPacket` when it came first and returned the+`OK` when it came later); the query functions raise it after the first result+set's rows when a second one follows, which 1.3.1 left on the connection. A+later statement that fails raises its `ERRException` from that call or its row+stream, also ahead of `ExtraResultSets`. Stack users who set the removed+`crypton-1-1` flag must delete it from `stack.yaml`, since Stack rejects flags a+package does not define.+++ Query functions no longer hang on statements without a result set,+  multi-result replies are read to the end, and crypton 2 is required+  [#91](https://github.com/winterland1989/mysql-haskell/pull/91)++ CI: the cabal job builds and runs the test suite+  [#90](https://github.com/winterland1989/mysql-haskell/pull/90)+ ## 1.3.1 -- 2026.10.06 + Allow crypton 2.0 and 2.1 (commercialhaskell/stackage#8128). The library   code needed no change; CI now builds and tests against every crypton
mysql-haskell.cabal view
@@ -1,6 +1,6 @@ cabal-version:      3.4 name:               mysql-haskell-version:            1.3.1+version:            1.3.2 synopsis:           pure haskell MySQL driver description:        pure haskell MySQL driver. license:            BSD-3-Clause@@ -42,11 +42,6 @@   type:     git   location: https://github.com/winterland1989/mysql-haskell -flag crypton-1-1-  description: Build with crypton >= 1.1.0, including the 2.x series (uses ram instead of memory for ByteArrayAccess)-  default: True-  manual: False- common common-options   ghc-options:     -Wall -Wincomplete-uni-patterns@@ -113,14 +108,14 @@     vector >=0.8 && <0.13 || ^>=0.13.0,     word-compat >=0.0 && <0.1 -  if flag(crypton-1-1)-    build-depends:-      crypton ^>=1.1.0 || ^>=2.0.0 || ^>=2.1.0,-      ram >=0.20 && <0.23-  else-    build-depends:-      crypton >=0.31 && <0.40 || ^>=1.0.0,-      memory >=0.14.4 && <0.19+  -- Decision: require crypton 2.0, the first release with its side-channel+  -- fixes (none backported to 1.x). Before it, P-384/P-521 point multiplication+  -- and DH exponentiation took time that followed the secret, and tls runs them+  -- on the client's ephemeral key whenever the server picks such a group. 2.0+  -- rather than 2.1.x, as 2.0 has every fix we reach.+  build-depends:+    crypton ^>=2.0.0 || ^>=2.1.0,+    ram >=0.20 && <0.23    default-extensions:     DeriveDataTypeable@@ -188,7 +183,9 @@     BinLogNew     CachingSha2     ExecuteMany+    MultipleResults     MysqlTests+    QueryWithoutResultSet     RoundtripBit     RoundtripYear     SelectOne
src/Database/MySQL/Base.hs view
@@ -20,6 +20,8 @@     * 'UnexpectedPacket':  you receive a unexpected packet when you shouldn't.     * 'DecodePacketException': there's a packet we can't decode.     * 'WrongParamsCount': you're giving wrong number of params to 'renderParams'.+    * 'ExtraResultSets': a statement produced a result-set the function running it+      could not return.  Both 'UnexpectedPacket' and 'DecodePacketException' may indicate a bug of this library rather your code, so please report! @@ -68,6 +70,7 @@     , UnexpectedPacket(..)     , DecodePacketException(..)     , WrongParamsCount(..)+    , ExtraResultSets(..)       -- * MySQL protocol     , module  Database.MySQL.Protocol.Auth     , module  Database.MySQL.Protocol.Command@@ -78,8 +81,10 @@  import           Control.Exception                  (mask, onException, throwIO) import           Control.Monad+import           Data.Binary                        (Get)+import           Data.Bits                          ((.&.)) import qualified Data.ByteString.Lazy               as L-import           Data.IORef                         (writeIORef)+import           Data.IORef                         (IORef, newIORef, readIORef, writeIORef) import           Database.MySQL.Connection import           Database.MySQL.Protocol.Auth import           Database.MySQL.Protocol.ColumnDef@@ -139,8 +144,11 @@  -- | Execute a MySQL query which don't return a result-set. --+-- For a multi-statement query this is the first statement's 'OK'; the later+-- ones are read and discarded ('executeMany_' returns them all).+-- execute_ :: MySQLConn -> Query -> IO OK-execute_ conn (Query qry) = command conn (COM_QUERY qry)+execute_ conn (Query qry) = executeCommand conn (COM_QUERY qry)  -- | Execute a MySQL query which return a result-set with parameters. --@@ -166,24 +174,22 @@  -- | Execute a MySQL query which return a result-set. --+-- A statement without a result-set, such as an INSERT, gives no columns and no+-- rows; use 'execute_' to get its 'OK'.+-- query_ :: MySQLConn -> Query -> IO ([ColumnDef], InputStream [MySQLValue]) query_ conn@(MySQLConn is os _ consumed) (Query qry) = do     guardUnconsumed conn     writeCommand (COM_QUERY qry) os-    p <- readPacket is-    if isERR p-    then decodeFromPacket p >>= throwIO . ERRException-    else do-        len <- getFromPacket getLenEncInt p-        fields <- replicateM len $ (decodeFromPacket <=< readPacket) is-        _ <- readPacket is -- eof packet, we don't verify this though-        writeIORef consumed False-        rows <- Stream.makeInputStream $ do-            q <- readPacket is-            if  | isEOF q  -> writeIORef consumed True >> return Nothing-                | isERR q  -> decodeFromPacket q >>= throwIO . ERRException-                | otherwise -> Just <$> getFromPacket (getTextRow fields) q-        return (fields, rows)+    reply <- readQueryReply is+    case reply of+        WithoutResultSet -> (,) [] <$> Stream.nullInput+        ResultSetColumns len -> do+            fields <- replicateM len $ (decodeFromPacket <=< readPacket) is+            _ <- readPacket is -- eof packet, we don't verify this though+            writeIORef consumed False+            rows <- resultSetRows is consumed (getFromPacket (getTextRow fields))+            return (fields, rows)  -- | 'V.Vector' version of 'query_'. --@@ -193,21 +199,126 @@ queryVector_ conn@(MySQLConn is os _ consumed) (Query qry) = do     guardUnconsumed conn     writeCommand (COM_QUERY qry) os+    reply <- readQueryReply is+    case reply of+        WithoutResultSet -> (,) V.empty <$> Stream.nullInput+        ResultSetColumns len -> do+            fields <- V.replicateM len $ (decodeFromPacket <=< readPacket) is+            _ <- readPacket is -- eof packet, we don't verify this though+            writeIORef consumed False+            rows <- resultSetRows is consumed (getFromPacket (getTextRowVector fields))+            return (fields, rows)++-- | How the server answered a statement sent through a query function.+data QueryReply+    = WithoutResultSet      -- ^ only OK packets, e.g. for an INSERT+    | ResultSetColumns Int  -- ^ a result-set with this many columns follows++-- | Read the reply to a query function's statement up to its first result-set.+--+-- An OK packet must be told apart here: its leading 0x00 also decodes as a+-- column count of zero, which used to leave the caller waiting forever for an+-- EOF packet the server never sends (issue #47). OKs flagged with more results+-- are skipped, so @SET \@x := 1; SELECT \@x@ gives the SELECT's rows.+--+-- Decision: answer such a statement with an empty result-set rather than an+-- exception, so code that worked around the hang keeps working and the fix is+-- not a breaking change. The OK's affected-rows count is dropped; 'execute_'+-- returns it.+readQueryReply :: InputStream Packet -> IO QueryReply+readQueryReply is = do     p <- readPacket is-    if isERR p-    then decodeFromPacket p >>= throwIO . ERRException-    else do-        len <- getFromPacket getLenEncInt p-        fields <- V.replicateM len $ (decodeFromPacket <=< readPacket) is-        _ <- readPacket is -- eof packet, we don't verify this though-        writeIORef consumed False-        rows <- Stream.makeInputStream $ do+    if  | isERR p -> decodeFromPacket p >>= throwIO . ERRException+        | isOK  p -> do+            ok <- decodeFromPacket p+            if isThereMore ok then readQueryReply is else pure WithoutResultSet+        | otherwise -> ResultSetColumns <$> getFromPacket getLenEncInt p++-- | The rows of a result-set, read as they are asked for, up to its EOF packet.+--+-- Once finished, also when 'finishAfterEOF' throws, further reads answer+-- Nothing rather than wait on the socket for packets that will never come.+resultSetRows :: InputStream Packet -> IORef Bool -> (Packet -> IO row) -> IO (InputStream row)+resultSetRows is consumed decodeRow = do+    finished <- newIORef False+    Stream.makeInputStream $ do+        alreadyFinished <- readIORef finished+        if alreadyFinished then pure Nothing else do             q <- readPacket is-            if  | isEOF q  -> writeIORef consumed True >> return Nothing-                | isERR q  -> decodeFromPacket q >>= throwIO . ERRException-                | otherwise -> Just <$> getFromPacket (getTextRowVector fields) q-        return (fields, rows)+            if  | isEOF q -> do+                    writeIORef finished True+                    writeIORef consumed True+                    finishAfterEOF is q+                    pure Nothing+                | isERR q -> decodeFromPacket q >>= throwIO . ERRException+                | otherwise -> Just <$> decodeRow q +-- | A binary-protocol row, whose packets start with 0x00 like an OK packet.+decodeBinaryRow :: Get row -> Packet -> IO row+decodeBinaryRow getRow q =+    if isOK q then getFromPacket getRow q else throwIO (UnexpectedPacket q)++-- | What followed a reply flagged SERVER_MORE_RESULTS_EXISTS.+data FurtherResults+    = OnlyOKs        -- ^ e.g. the closing OK of a CALL, or more INSERTs' OKs+    | SomeResultSet  -- ^ at least one result-set, which was discarded++-- | Finish a reply that ended in this 'OK', see 'skipFurtherResults'.+finishAfterOK :: InputStream Packet -> OK -> IO ()+finishAfterOK is ok =+    when (isThereMore ok) (skipFurtherResults OnlyOKs is >>= throwOnResultSet)++-- | Finish a result-set at its EOF packet, see 'skipFurtherResults'.+finishAfterEOF :: InputStream Packet -> Packet -> IO ()+finishAfterEOF is eofPacket = do+    eof <- decodeFromPacket eofPacket+    when (isThereMoreAfterEOF eof) (skipFurtherResults OnlyOKs is >>= throwOnResultSet)++-- | SERVER_MORE_RESULTS_EXISTS on a result-set's closing EOF packet.+isThereMoreAfterEOF :: EOF -> Bool+isThereMoreAfterEOF eof = eofStatus eof .&. 0x08 /= 0++-- | Read every result that follows a reply flagged SERVER_MORE_RESULTS_EXISTS.+--+-- The client asks for multi-statements and multi-results, so a CALL or a query+-- holding several statements gets one result per statement. Any left unread+-- would be taken by the next command as its own reply, shifting every later+-- query's results by one.+skipFurtherResults :: FurtherResults -> InputStream Packet -> IO FurtherResults+skipFurtherResults seen is = do+    p <- readPacket is+    if  | isERR p -> decodeFromPacket p >>= throwIO . ERRException+        | isOK  p -> do+            ok <- decodeFromPacket p+            if isThereMore ok then skipFurtherResults seen is else pure seen+        | otherwise -> do+            eof <- skipResultSet is p+            if isThereMoreAfterEOF eof+            then skipFurtherResults SomeResultSet is+            else pure SomeResultSet++-- | Read the result-set this column-count packet starts, returning its closing+-- EOF packet.+skipResultSet :: InputStream Packet -> Packet -> IO EOF+skipResultSet is columnCountPacket = do+    columnCount <- getFromPacket getLenEncInt columnCountPacket+    replicateM_ columnCount (readPacket is)+    _ <- readPacket is -- eof packet after the column definitions+    skipRows is++-- | Read a result-set's rows up to and including the EOF packet that ends them.+skipRows :: InputStream Packet -> IO EOF+skipRows is = do+    q <- readPacket is+    if  | isEOF q -> decodeFromPacket q+        | isERR q -> decodeFromPacket q >>= throwIO . ERRException+        | otherwise -> skipRows is++throwOnResultSet :: FurtherResults -> IO ()+throwOnResultSet further = case further of+    OnlyOKs -> pure ()+    SomeResultSet -> throwIO ExtraResultSets+ -- | Ask MySQL to prepare a query statement. -- prepareStmt :: MySQLConn -> Query -> IO StmtID@@ -265,31 +376,45 @@ -- executeStmt :: MySQLConn -> StmtID -> [MySQLValue] -> IO OK executeStmt conn stid params =-  command conn (COM_STMT_EXECUTE stid params (makeNullMap params))+  executeCommand conn (COM_STMT_EXECUTE stid params (makeNullMap params)) +-- | Send a statement that should answer with an 'OK' and read its whole reply,+-- see 'skipFurtherResults'. A result-set, also as the first reply (a SELECT or+-- a CALL running one), is read off the connection and raises 'ExtraResultSets',+-- unless a later statement fails: its 'ERRException' is raised instead.+executeCommand :: MySQLConn -> Command -> IO OK+executeCommand conn@(MySQLConn is os _ _) cmd = do+    guardUnconsumed conn+    writeCommand cmd os+    p <- readPacket is+    if  | isERR p -> decodeFromPacket p >>= throwIO . ERRException+        | isOK  p -> do+            ok <- decodeFromPacket p+            finishAfterOK is ok+            pure ok+        | otherwise -> do+            eof <- skipResultSet is p+            when (isThereMoreAfterEOF eof) (void (skipFurtherResults SomeResultSet is))+            throwIO ExtraResultSets+ -- | Execute prepared query statement with parameters, expecting resultset. ----- Rules about 'UnconsumedResultSet' applied here too.+-- Rules about 'UnconsumedResultSet' applied here too. A statement without a+-- result-set gives no columns and no rows; use 'executeStmt' to get its 'OK'. -- queryStmt :: MySQLConn -> StmtID -> [MySQLValue] -> IO ([ColumnDef], InputStream [MySQLValue]) queryStmt conn@(MySQLConn is os _ consumed) stid params = do     guardUnconsumed conn     writeCommand (COM_STMT_EXECUTE stid params (makeNullMap params)) os-    p <- readPacket is-    if isERR p-    then decodeFromPacket p >>= throwIO . ERRException-    else do-        len <- getFromPacket getLenEncInt p-        fields <- replicateM len $ (decodeFromPacket <=< readPacket) is-        _ <- readPacket is -- eof packet, we don't verify this though-        writeIORef consumed False-        rows <- Stream.makeInputStream $ do-            q <- readPacket is-            if  | isOK  q  -> Just <$> getFromPacket (getBinaryRow fields len) q-                | isEOF q  -> writeIORef consumed True >> return Nothing-                | isERR q  -> decodeFromPacket q >>= throwIO . ERRException-                | otherwise -> throwIO (UnexpectedPacket q)-        return (fields, rows)+    reply <- readQueryReply is+    case reply of+        WithoutResultSet -> (,) [] <$> Stream.nullInput+        ResultSetColumns len -> do+            fields <- replicateM len $ (decodeFromPacket <=< readPacket) is+            _ <- readPacket is -- eof packet, we don't verify this though+            writeIORef consumed False+            rows <- resultSetRows is consumed (decodeBinaryRow (getBinaryRow fields len))+            return (fields, rows)  -- | 'V.Vector' version of 'queryStmt' --@@ -299,21 +424,15 @@ queryStmtVector conn@(MySQLConn is os _ consumed) stid params = do     guardUnconsumed conn     writeCommand (COM_STMT_EXECUTE stid params (makeNullMap params)) os-    p <- readPacket is-    if isERR p-    then decodeFromPacket p >>= throwIO . ERRException-    else do-        len <- getFromPacket getLenEncInt p-        fields <- V.replicateM len $ (decodeFromPacket <=< readPacket) is-        _ <- readPacket is -- eof packet, we don't verify this though-        writeIORef consumed False-        rows <- Stream.makeInputStream $ do-            q <- readPacket is-            if  | isOK  q  -> Just <$> getFromPacket (getBinaryRowVector fields len) q-                | isEOF q  -> writeIORef consumed True >> return Nothing-                | isERR q  -> decodeFromPacket q >>= throwIO . ERRException-                | otherwise -> throwIO (UnexpectedPacket q)-        return (fields, rows)+    reply <- readQueryReply is+    case reply of+        WithoutResultSet -> (,) V.empty <$> Stream.nullInput+        ResultSetColumns len -> do+            fields <- V.replicateM len $ (decodeFromPacket <=< readPacket) is+            _ <- readPacket is -- eof packet, we don't verify this though+            writeIORef consumed False+            rows <- resultSetRows is consumed (decodeBinaryRow (getBinaryRowVector fields len))+            return (fields, rows)  -- | Run querys inside a transaction, querys will be rolled back if exception arise. --
src/Database/MySQL/Connection.hs view
@@ -1,5 +1,3 @@-{-# LANGUAGE CPP #-}-{-# LANGUAGE PackageImports #-}  {-| Module      : Database.MySQL.Connection@@ -18,7 +16,8 @@     ( module Database.MySQL.Connection     ) where -import           Control.Exception               (Exception, bracketOnError,+import           Control.Exception               (Exception (displayException),+                                                  bracketOnError,                                                   throwIO, catch, SomeException) import           Control.Monad import qualified Crypto.Hash                     as Crypto@@ -33,11 +32,7 @@ import qualified Data.Binary                     as Binary import qualified Data.Binary.Put                 as Binary import           Data.Bits-#if MIN_VERSION_crypton(1,1,0)-import qualified "ram" Data.ByteArray             as BA-#else-import qualified "memory" Data.ByteArray          as BA-#endif+import qualified Data.ByteArray                  as BA import           Data.ByteString                 (ByteString) import qualified Data.ByteString                 as B import qualified Data.ByteString.Lazy            as L@@ -468,4 +463,17 @@  data UnexpectedPacket = UnexpectedPacket Packet deriving (Typeable, Show) instance Exception UnexpectedPacket++-- | A statement produced a result set the function running it could not+-- return: @execute@ and @executeStmt@ return none, the query functions only the+-- first. It was read and discarded, so the connection is still usable.+--+-- @since 1.3.2+data ExtraResultSets = ExtraResultSets deriving (Typeable, Show)+instance Exception ExtraResultSets where+    displayException ExtraResultSets =+        "mysql-haskell: the statement produced a result set this function could not "+        ++ "return (execute_ and executeStmt return none, query_ and queryStmt only the "+        ++ "first). It was read and discarded, so the connection is still usable. Run "+        ++ "statements that return rows through a query function, one result set per call." 
test/Integration.hs view
@@ -5,7 +5,9 @@ import           System.Environment (lookupEnv) import           Test.Tasty (defaultMain, testGroup) import qualified CachingSha2+import qualified MultipleResults import qualified MysqlTests+import qualified QueryWithoutResultSet import qualified RoundtripBit import qualified RoundtripYear import qualified SelectOne@@ -32,6 +34,8 @@         , RoundtripBit.tests         , RoundtripYear.tests         , MysqlTests.tests+        , QueryWithoutResultSet.tests+        , MultipleResults.tests         ]         -- caching_sha2_password is MySQL 8.0+ only (MariaDB does not support it).         -- The sha2 test users are created by the nix CI config for the MySQL 8.0 VM.
+ test/MultipleResults.hs view
@@ -0,0 +1,107 @@+-- | Statements whose reply holds several results: multi-statement strings and+-- CALLs. Whatever a function cannot hand back must still be read off the+-- connection, or the next query receives the previous one's leftovers.+module MultipleResults (tests) where++import           Control.Exception (try)+import           Data.Int          (Int64)+import           Database.MySQL.Base+import qualified System.IO.Streams as Stream+import           System.Timeout    (timeout)+import           Test.Tasty+import           Test.Tasty.HUnit++tests :: TestTree+tests = testGroup "statements with several results"+    [ testCase "query_ with two INSERTs" $ withTable $ \c -> do+        (_, rows) <- query_ c+            "INSERT INTO several_results VALUES (1); INSERT INTO several_results VALUES (2)"+        Stream.skipToEof rows+        assertRowCount c 2+    , testCase "execute_ with two INSERTs" $ withTable $ \c -> do+        _ <- execute_ c+            "INSERT INTO several_results VALUES (1); INSERT INTO several_results VALUES (2)"+        assertRowCount c 2+    , testCase "execute_ with a SELECT" $ withTable $ \c ->+        assertExtraResultSets c (execute_ c "SELECT COUNT(*) FROM several_results")+    , testCase "executeStmt with a SELECT" $ withTable $ \c -> do+        stmt <- prepareStmt c "SELECT COUNT(*) FROM several_results"+        assertExtraResultSets c (executeStmt c stmt [])+    , testCase "execute_ with a CALL returning one result set" $ withTable $ \c -> do+        _ <- execute_ c "DROP PROCEDURE IF EXISTS one_result_set"+        _ <- execute_ c "CREATE PROCEDURE one_result_set() SELECT COUNT(*) FROM several_results"+        assertExtraResultSets c (execute_ c "CALL one_result_set()")+    , testCase "query_ with a CALL returning one result set" $ withTable $ \c -> do+        _ <- execute_ c "DROP PROCEDURE IF EXISTS one_result_set"+        _ <- execute_ c "CREATE PROCEDURE one_result_set() SELECT COUNT(*) FROM several_results"+        (_, rows) <- query_ c "CALL one_result_set()"+        counts <- Stream.toList rows+        assertEqual "rows of the procedure's SELECT" [[MySQLInt64 0]] counts+        assertRowCount c 0+    , testCase "query_ with an INSERT before a SELECT" $ withTable $ \c -> do+        (_, rows) <- query_ c+            "INSERT INTO several_results VALUES (1); SELECT COUNT(*) FROM several_results"+        counts <- Stream.toList rows+        assertEqual "rows of the SELECT" [[MySQLInt64 1]] counts+        assertRowCount c 1+    , testCase "query_ with two SELECTs" $ withTable $ \c -> do+        (_, rows) <- query_ c+            "SELECT COUNT(*) FROM several_results; SELECT COUNT(*) FROM several_results"+        outcome <- try (Stream.toList rows)+        case outcome of+            Left ExtraResultSets -> pure ()+            Right _ -> assertFailure "the second SELECT's result set went unreported"+        again <- Stream.read rows+        assertEqual "reading the finished rows again" Nothing again+        assertRowCount c 0+    , testCase "query_ with a failing statement after a SELECT" $ withTable $ \c -> do+        (_, rows) <- query_ c+            "SELECT COUNT(*) FROM several_results; SELECT * FROM no_such_table"+        assertERRException c (Stream.toList rows)+    , testCase "execute_ with a failing statement after a SELECT" $ withTable $ \c ->+        assertERRException c+            (execute_ c "SELECT COUNT(*) FROM several_results; SELECT * FROM no_such_table")+    ]++-- | Generous for a few statements on an empty temporary table, and short enough+-- that a hang fails the suite instead of stalling CI.+replyTimeLimitMicroseconds :: Int+replyTimeLimitMicroseconds = 10000000++-- | Runs a case on a fresh connection with an empty temporary table.+withTable :: (MySQLConn -> Assertion) -> Assertion+withTable body = do+    (_, c) <- connectDetail defaultConnectInfo+        { ciUser = "testMySQLHaskell"+        , ciDatabase = "testMySQLHaskell"+        }+    _ <- execute_ c "CREATE TEMPORARY TABLE several_results (__id INT)"+    finished <- timeout replyTimeLimitMicroseconds (body c)+    case finished of+        Nothing -> assertFailure "blocked for 10 s waiting for a reply"+        Just () -> close c++-- | The statement must raise 'ExtraResultSets' and leave the connection in step.+assertExtraResultSets :: MySQLConn -> IO OK -> Assertion+assertExtraResultSets c runStatement = do+    outcome <- try runStatement+    case outcome of+        Left ExtraResultSets -> pure ()+        Right _ -> assertFailure "the result set went unreported"+    assertRowCount c 0++-- | The failing statement's error must come from this call, not the next one.+assertERRException :: MySQLConn -> IO a -> Assertion+assertERRException c runStatement = do+    outcome <- try runStatement+    case outcome of+        Left (ERRException _) -> pure ()+        Right _ -> assertFailure "the failing statement's error went unreported"+    assertRowCount c 0++-- | The connection must be back in step: a fresh query gets its own rows.+assertRowCount :: MySQLConn -> Int64 -> Assertion+assertRowCount c expected = do+    (_, rows) <- query_ c "SELECT COUNT(*) FROM several_results"+    counts <- Stream.toList rows+    assertEqual "rows of the next query" [[MySQLInt64 expected]] counts
+ test/QueryWithoutResultSet.hs view
@@ -0,0 +1,60 @@+-- | Statements without a result set (INSERT, UPDATE, DELETE) sent through the+-- functions that expect one. The server answers with a lone OK packet, which+-- used to leave these functions blocked forever waiting for column+-- definitions that never come (issue #47).+module QueryWithoutResultSet (tests) where++import           Database.MySQL.Base+import qualified Data.Vector       as V+import qualified System.IO.Streams as Stream+import           System.Timeout    (timeout)+import           Test.Tasty+import           Test.Tasty.HUnit++tests :: TestTree+tests = testGroup "query functions given an INSERT"+    [ testCase "query_" $ assertEmptyResultSet $ \c -> do+        (columns, rows) <- query_ c "INSERT INTO without_result_set VALUES (1)"+        rowList <- Stream.toList rows+        pure (length columns, length rowList)+    , testCase "queryVector_" $ assertEmptyResultSet $ \c -> do+        (columns, rows) <- queryVector_ c "INSERT INTO without_result_set VALUES (1)"+        rowList <- Stream.toList rows+        pure (V.length columns, length rowList)+    , testCase "queryStmt" $ assertEmptyResultSet $ \c -> do+        stmt <- prepareStmt c "INSERT INTO without_result_set VALUES (?)"+        (columns, rows) <- queryStmt c stmt [MySQLInt32 1]+        rowList <- Stream.toList rows+        pure (length columns, length rowList)+    , testCase "queryStmtVector" $ assertEmptyResultSet $ \c -> do+        stmt <- prepareStmt c "INSERT INTO without_result_set VALUES (?)"+        (columns, rows) <- queryStmtVector c stmt [MySQLInt32 1]+        rowList <- Stream.toList rows+        pure (V.length columns, length rowList)+    ]++-- | Generous for one INSERT into an empty temporary table, and short enough+-- that a hang fails the suite instead of stalling CI.+replyTimeLimitMicroseconds :: Int+replyTimeLimitMicroseconds = 10000000++-- | Runs the insert on a fresh connection; it reports the column and row counts+-- of the result set it got back. Both must be zero, the row must be inserted+-- exactly once, and the connection must stay usable afterwards.+assertEmptyResultSet :: (MySQLConn -> IO (Int, Int)) -> Assertion+assertEmptyResultSet runInsert = do+    (_, c) <- connectDetail defaultConnectInfo+        { ciUser = "testMySQLHaskell"+        , ciDatabase = "testMySQLHaskell"+        }+    _ <- execute_ c "CREATE TEMPORARY TABLE without_result_set (__id INT)"+    outcome <- timeout replyTimeLimitMicroseconds (runInsert c)+    case outcome of+        Nothing -> assertFailure+            "blocked for 10 s waiting for a result set the server never sends"+        Just columnsAndRows -> do+            assertEqual "columns and rows returned" (0, 0) columnsAndRows+            (_, rows) <- query_ c "SELECT COUNT(*) FROM without_result_set"+            counts <- Stream.toList rows+            assertEqual "the row was inserted once" [[MySQLInt64 1]] counts+    close c