packages feed

mysql-haskell-1.4.0: src/Database/MariaDB.hs

{-|
Module      : Database.MariaDB
Description : MariaDB-only protocol features
License     : BSD
Stability   : experimental
Portability : PORTABLE

Features only MariaDB servers have, such as extended column metadata and bulk
execution. They rely on MariaDB's own protocol extensions, so this module
works with MariaDB servers only: its connect functions refuse a server that
does not speak them, MySQL or MariaDB before 10.2, with 'NotMariaDB'.

The functions here take a 'MariaDBConn', so they cannot be given a connection
to MySQL. A 'MariaDBConn' wraps an ordinary 'MySQLConn': use
'mysqlConnection' for everything in "Database.MySQL.Base".

A MariaDB server that does not offer a requested capability is not an error.
The connection is made without it, 'mariadbCapabilities' says what was agreed,
and a function that needs the missing capability raises
'CapabilityNotNegotiated'.

@since 1.4.0
-}
module Database.MariaDB
    ( -- * Capabilities
      MariaDBCapability(..)
      -- * Connections
    , MariaDBConn
    , mysqlConnection
    , mariadbGreeting
    , mariadbCapabilities
    , connectMariaDB
    , connectMariaDBUnixSocket
    , connectMariaDBTLS
      -- * Extended column metadata
    , ColumnMetadata(..)
    , ExtendedMetadata(..)
    , prepareStmtMetadata
      -- * Bulk execution
    , BulkParam(..)
    , executeBulk
    , BulkExecuteError(..)
      -- * Exceptions
    , NotMariaDB(..)
    , CapabilityNotNegotiated(..)
    ) where

import           Control.Exception              (Exception (displayException), throwIO)
import           Control.Monad                  (forM_, replicateM, unless, void)
import           Data.Binary.Get                (Get, getWord8, isEmpty)
import           Data.Binary.Put                (Put, putLazyByteString, putWord16le, putWord32le,
                                                 putWord8, runPut)
import           Data.Binary.Parser             (parseOnly)
import           Data.Bits                      ((.&.), (.|.))
import           Data.ByteString                (ByteString)
import qualified Data.ByteString.Lazy           as L
import           Data.List                      (nub, transpose)
import           Data.List.NonEmpty             (NonEmpty ((:|)))
import qualified Data.List.NonEmpty             as NonEmpty
import           Data.Word                      (Word32, Word8)
import           Database.MySQL.Base
import qualified Database.MySQL.Connection      as Connection
import qualified Database.MySQL.TLS             as TLS
import qualified Network.TLS                    as Network.TLS

-- | A capability only MariaDB servers offer. Only capabilities whose replies
-- this library can read are listed, since asking for one changes what the
-- server sends.
data MariaDBCapability
    = MariaDBExtendedMetadata
      -- ^ MARIADB_CLIENT_EXTENDED_METADATA: column definitions name types
      -- such as JSON or UUID, see 'prepareStmtMetadata'.
    | MariaDBBulkOperations
      -- ^ MARIADB_CLIENT_STMT_BULK_OPERATIONS: 'executeBulk'.
    deriving (Show, Eq, Ord, Enum, Bounded)

-- | Bits 32 to 63 of the capability flags, see
-- Note [MariaDB extended capabilities] in "Database.MySQL.Protocol.Auth".
capabilityBit :: MariaDBCapability -> Word32
capabilityBit MariaDBExtendedMetadata = 0x08  -- 1 << (35 - 32)
capabilityBit MariaDBBulkOperations   = 0x04  -- 1 << (34 - 32)

capabilityBits :: [MariaDBCapability] -> ExtendedCapabilities
capabilityBits = ExtendedCapabilities . foldr ((.|.) . capabilityBit) 0

negotiatedCapabilities :: ExtendedCapabilities -> [MariaDBCapability]
negotiatedCapabilities (ExtendedCapabilities bits) =
    filter (\capability -> bits .&. capabilityBit capability /= 0) [minBound .. maxBound]

-- | A connection together with the MariaDB capabilities it negotiated.
data MariaDBConn = MariaDBConn
    { mysqlConnection     :: !MySQLConn
      -- ^ The underlying connection, for everything in "Database.MySQL.Base";
      -- closing it closes the 'MariaDBConn'.
    , mariadbGreeting     :: !Greeting
    , mariadbCapabilities :: ![MariaDBCapability]
      -- ^ Those asked for that the server offered.
    }

hasCapability :: MariaDBCapability -> MariaDBConn -> Bool
hasCapability capability conn = capability `elem` mariadbCapabilities conn

-- | 'Database.MySQL.Base.connect' asking for these capabilities. Raises
-- 'NotMariaDB', after closing the connection, when the server does not speak
-- MariaDB's protocol extensions (MySQL, or MariaDB before 10.2).
connectMariaDB :: [MariaDBCapability] -> ConnectInfo -> IO MariaDBConn
connectMariaDB requested ci =
    Connection.connectDetailExtended (capabilityBits requested) ci >>= mariadbConn

-- | 'Database.MySQL.Base.connectUnixSocket' asking for these capabilities, and
-- refusing a MySQL server as 'connectMariaDB' does.
connectMariaDBUnixSocket :: [MariaDBCapability] -> FilePath -> ConnectInfo -> IO MariaDBConn
connectMariaDBUnixSocket requested socketPath ci =
    Connection.connectUnixSocketDetailExtended (capabilityBits requested) socketPath ci >>= mariadbConn

-- | 'Database.MySQL.TLS.connect' asking for these capabilities, and refusing a
-- MySQL server as 'connectMariaDB' does.
connectMariaDBTLS :: [MariaDBCapability] -> ConnectInfo -> (Network.TLS.ClientParams, String) -> IO MariaDBConn
connectMariaDBTLS requested ci tlsParams =
    TLS.connectDetailExtended (capabilityBits requested) ci tlsParams >>= mariadbConn

-- | Refuses a server whose greeting does not announce MariaDB's protocol
-- extensions, after closing the connection. The greeting's capabilities decide
-- this, not its version string, which @mariadbd --version@ can set to anything.
mariadbConn :: (Greeting, ExtendedCapabilities, MySQLConn) -> IO MariaDBConn
mariadbConn (greeting, negotiated, conn) =
    if offersExtendedCapabilities greeting
    then pure (MariaDBConn conn greeting (negotiatedCapabilities negotiated))
    else do
        close conn
        throwIO (NotMariaDB (greetingVersion greeting))

-- | A column definition with the MariaDB extended metadata the server sent
-- for it.
data ColumnMetadata = ColumnMetadata
    { columnDefinition :: !ColumnDef
    , columnExtended   :: ![ExtendedMetadata]
      -- ^ Empty for columns of ordinary types.
    } deriving (Show, Eq)

-- | One entry of a column's extended metadata.
data ExtendedMetadata
    = ExtendedTypeName !ByteString
      -- ^ The SQL type behind a generic wire type, e.g. @uuid@ or @inet6@.
    | ExtendedFormat !ByteString
      -- ^ How a value is formatted, e.g. @json@ for a JSON column sent as a
      -- string.
    | ExtendedUnrecognised !Word8 !ByteString
      -- ^ An entry kind newer than this library, kept as sent.
    deriving (Show, Eq)

-- | Parse a column definition from a connection with
-- 'MariaDBExtendedMetadata', or without it, giving no extended metadata.
getColumnMetadata :: Get ColumnMetadata
getColumnMetadata = do
    (column, block) <- getFieldExtended
    case parseOnly getExtendedEntries block of
        Left err      -> fail ("MariaDB extended column metadata: " ++ err)
        Right entries -> pure (ColumnMetadata column entries)

getExtendedEntries :: Get [ExtendedMetadata]
getExtendedEntries = do
    done <- isEmpty
    if done then pure [] else (:) <$> getExtendedEntry <*> getExtendedEntries

getExtendedEntry :: Get ExtendedMetadata
getExtendedEntry = extendedEntry <$> getWord8 <*> getLenEncBytes

extendedEntry :: Word8 -> ByteString -> ExtendedMetadata
extendedEntry kind = if
    | kind == 0 -> ExtendedTypeName
    | kind == 1 -> ExtendedFormat
    | otherwise -> ExtendedUnrecognised kind

-- | 'Database.MySQL.Base.prepareStmtDetail' with the extended metadata of
-- every parameter and result column. Use the returned statement with the
-- usual functions of "Database.MySQL.Base".
--
-- Raises 'CapabilityNotNegotiated' without 'MariaDBExtendedMetadata', as the
-- server then sends no extended metadata to return.
--
-- Decision: offered for prepared statements only. A text query variant needs
-- the result-set reader that "Database.MySQL.Base" keeps internal, and
-- preparing a query is how a caller learns its column types up front anyway.
prepareStmtMetadata :: MariaDBConn -> Query -> IO (StmtPrepareOK, [ColumnMetadata], [ColumnMetadata])
prepareStmtMetadata conn (Query stmt) = do
    requireCapability MariaDBExtendedMetadata conn
    let mysqlConn = mysqlConnection conn
        is = Connection.mysqlRead mysqlConn
    Connection.guardUnconsumed mysqlConn
    Connection.writeCommand (COM_STMT_PREPARE stmt) (Connection.mysqlWrite mysqlConn)
    p <- Connection.readPacket is
    if isERR p
    then decodeFromPacket p >>= throwIO . ERRException
    else do
        prepared@(StmtPrepareOK _ columnCount paramCount _) <- getFromPacket getStmtPrepareOK p
        params <- replicateM paramCount (Connection.readPacket is >>= getFromPacket getColumnMetadata)
        unless (paramCount == 0) (void (Connection.readPacket is))  -- EOF
        columns <- replicateM columnCount (Connection.readPacket is >>= getFromPacket getColumnMetadata)
        unless (columnCount == 0) (void (Connection.readPacket is))  -- EOF
        pure (prepared, params, columns)

-- | One parameter of one row given to 'executeBulk'.
data BulkParam
    = BulkValue !MySQLValue
      -- ^ 'MySQLNull' sends SQL NULL.
    | BulkDefault
      -- ^ The column's default, for INSERT and UPDATE.
    | BulkIgnore
      -- ^ The column's default for INSERT; UPDATE leaves the column as it is.
    deriving (Show, Eq)

-- | Rows 'executeBulk' refuses before sending anything, so the connection
-- stays usable.
data BulkExecuteError
    = BulkNoParameters
      -- ^ The rows have no parameters; a bulk command sends parameter rows.
    | BulkRowWidth !Int !Int !Int
      -- ^ Row number (counting from 0), the first row's width, this row's width.
    | BulkMixedTypes !Int ![MySQLValue]
      -- ^ Parameter number (counting from 0) and one value of each type found
      -- there. The command declares one type per parameter for every row.
    deriving (Show, Eq)

instance Exception BulkExecuteError where
    displayException err = "mysql-haskell: executeBulk: " ++ case err of
        BulkNoParameters -> "the rows have no parameters"
        BulkRowWidth row expected found ->
            "row " ++ show row ++ " has " ++ show found ++ " parameters, the first row has " ++ show expected
        BulkMixedTypes param examples ->
            "parameter " ++ show param ++ " mixes value types " ++ show examples
            ++ "; give every row the same MySQLValue constructor there"

-- | Execute a prepared statement once per row in a single round trip
-- (COM_STMT_BULK_EXECUTE). The 'OK' sums the affected rows of every
-- execution. A statement that returns rows is refused by the server with an
-- 'ERRException'.
--
-- Every row must have the same number of parameters, and each parameter the
-- same 'MySQLValue' constructor in every row where it is a 'BulkValue' other
-- than 'MySQLNull', or 'BulkExecuteError' is raised. Raises
-- 'CapabilityNotNegotiated' without 'MariaDBBulkOperations'; MySQL has no
-- bulk command.
--
-- The rows travel in one command, which the server's @max_allowed_packet@
-- limits; split larger inputs over several calls.
--
-- > import Data.List.NonEmpty (NonEmpty ((:|)))
-- >
-- > conn <- connectMariaDB [MariaDBBulkOperations] defaultConnectInfo
-- > let mysqlConn = mysqlConnection conn
-- > _ <- execute_ mysqlConn "CREATE TABLE people (name VARCHAR(20), age INT DEFAULT 18)"
-- > insert <- prepareStmt mysqlConn "INSERT INTO people (name, age) VALUES (?, ?)"
-- > ok <- executeBulk conn insert
-- >     (  [BulkValue (MySQLText "Ada"),   BulkValue (MySQLInt32 36)]
-- >     :| [ [BulkValue (MySQLText "Alan"),  BulkValue MySQLNull]  -- age NULL
-- >        , [BulkValue (MySQLText "Grace"), BulkDefault]          -- age 18
-- >        ])
-- > okAffectedRows ok  -- 3
executeBulk :: MariaDBConn -> StmtID -> NonEmpty [BulkParam] -> IO OK
executeBulk conn stid rows = do
    requireCapability MariaDBBulkOperations conn
    types <- either throwIO pure (bulkParamTypes rows)
    let mysqlConn = mysqlConnection conn
        is = Connection.mysqlRead mysqlConn
    Connection.guardUnconsumed mysqlConn
    Connection.writePayload (runPut (putBulkExecute stid types rows)) (Connection.mysqlWrite mysqlConn)
    p <- Connection.readPacket is
    if  | isERR p -> decodeFromPacket p >>= throwIO . ERRException
        | isOK  p -> decodeFromPacket p
        | otherwise -> throwIO (UnexpectedPacket p)

-- | The type each parameter is declared with: that of its values, or NULL's
-- when every row sends NULL, DEFAULT or IGNORE there.
bulkParamTypes :: NonEmpty [BulkParam] -> Either BulkExecuteError [L.ByteString]
bulkParamTypes rows@(firstRow :| _) =
    if null firstRow
    then Left BulkNoParameters
    else do
        mapM_ (checkRowWidth (length firstRow)) (zip [0 ..] (NonEmpty.toList rows))
        mapM paramType (zip [0 ..] (transpose (NonEmpty.toList rows)))

checkRowWidth :: Int -> (Int, [BulkParam]) -> Either BulkExecuteError ()
checkRowWidth expected (row, params) =
    if length params == expected then Right () else Left (BulkRowWidth row expected (length params))

paramType :: (Int, [BulkParam]) -> Either BulkExecuteError L.ByteString
paramType (param, column) =
    let values = concatMap typedValue column
        typed = nubOn (runPut . putParamMySQLType) values
    in case typed of
        []      -> Right (runPut (putParamMySQLType MySQLNull))
        [value] -> Right (runPut (putParamMySQLType value))
        (_ : _ : _) -> Left (BulkMixedTypes param typed)

typedValue :: BulkParam -> [MySQLValue]
typedValue (BulkValue MySQLNull) = []
typedValue (BulkValue value)     = [value]
typedValue BulkDefault           = []
typedValue BulkIgnore            = []

-- | One value per distinct key, in order of first appearance.
nubOn :: Eq key => (value -> key) -> [value] -> [value]
nubOn key values =
    let keys = nub (map key values)
    in concatMap (\k -> take 1 (filter ((== k) . key) values)) keys

-- Decision: always send the parameter types (SEND_TYPES_TO_SERVER, 128), as
-- mariadb-connector-c does. The server keeps no types from an earlier
-- COM_STMT_EXECUTE that a bulk command could rely on. Per-row results
-- (SEND_UNIT_RESULTS) need MARIADB_CLIENT_BULK_UNIT_RESULTS, which MariaDB
-- 11.4, the version CI runs, does not offer.
putBulkExecute :: StmtID -> [L.ByteString] -> NonEmpty [BulkParam] -> Put
putBulkExecute stid types rows = do
    putWord8 0xfa
    putWord32le stid
    putWord16le 128
    mapM_ putLazyByteString types
    forM_ rows (mapM_ putBulkParam)

putBulkParam :: BulkParam -> Put
putBulkParam (BulkValue MySQLNull) = putWord8 1
putBulkParam (BulkValue value)     = putWord8 0 >> putBinaryField value
putBulkParam BulkDefault           = putWord8 2
putBulkParam BulkIgnore            = putWord8 3

requireCapability :: MariaDBCapability -> MariaDBConn -> IO ()
requireCapability capability conn =
    unless (hasCapability capability conn) (throwIO (CapabilityNotNegotiated capability))

-- | A function needed a capability the connection did not negotiate, because
-- it was not asked for in 'connectMariaDB' or the server's MariaDB version
-- does not offer it.
newtype CapabilityNotNegotiated = CapabilityNotNegotiated MariaDBCapability
    deriving Show

instance Exception CapabilityNotNegotiated where
    displayException (CapabilityNotNegotiated capability) =
        "mysql-haskell: this function needs the MariaDB capability " ++ show capability
        ++ ", which the connection did not negotiate. Ask for it in connectMariaDB,"
        ++ " on a MariaDB version that offers it."

-- | A connect function of this module reached a server that does not speak
-- MariaDB's protocol extensions: MySQL, or MariaDB before 10.2. Carries the
-- server's version string.
newtype NotMariaDB = NotMariaDB ByteString
    deriving Show

instance Exception NotMariaDB where
    displayException (NotMariaDB version) =
        "mysql-haskell: Database.MariaDB needs a server speaking MariaDB's protocol"
        ++ " extensions, but server " ++ show version ++ " set CLIENT_MYSQL in its"
        ++ " greeting, as MySQL servers and MariaDB before 10.2 do. Connect with"
        ++ " Database.MySQL.Base for those servers, or upgrade to MariaDB 10.2 or later."