mysql-haskell-1.3.0: src/Database/MySQL/Field.hs
{-|
Module : Database.MySQL.Field
Description : Convert between Haskell values and 'MySQLValue' fields
Copyright : (c) Jappie Klooster, 2026
License : BSD3
Maintainer : hi@jappie.me
Stability : experimental
Portability : PORTABLE
A single typeclass, 'Field', for converting an individual Haskell value
to and from a 'MySQLValue'. This is the extension point libraries such as
@beam-mysql@ need to marshal user column types generically: encoding is
total ('toField'), decoding returns 'DecodeError' on the 'Left'
('fromField').
Every instance must obey the roundtrip law:
@
'fromField' ('toField' x) == 'Right' x
@
Two caveats:
* 'Maybe' wrapped around a type that itself encodes to @MySQLNull@
(@Maybe (Maybe a)@, @Maybe MySQLValue@): SQL cannot represent a
@Just NULL@ distinct from @NULL@, so both collapse to 'Nothing' on the
way back. Nullable columns want exactly one 'Maybe'.
* IEEE @NaN@ fails the equation literally because @NaN /= NaN@; the
'Float' and 'Double' encodings are still lossless and decoding returns
the same @NaN@.
All instances delegate to named top-level functions (@encodeInt8@,
@decodeInt8@, ...) so the conversions can be used, tested and grepped
without the class.
Decoding is strict and canonical: each Haskell type decodes only from the
constructor(s) carrying exactly its payload, with no implicit numeric
widening or narrowing. There are no instances for 'Int' and 'Word'
because their width is platform dependent; use 'Data.Int.Int64' or
'Data.Word.Word64'.
-}
module Database.MySQL.Field
( -- * The Field class
Field (..)
-- * Conversion errors
, DecodeError (..)
, ExpectedTypeName (..)
, EncodeStringError (..)
-- * Named conversions backing the instances
, encodeInt8, decodeInt8
, encodeWord8, decodeWord8
, encodeInt16, decodeInt16
, encodeWord16, decodeWord16
, encodeInt32, decodeInt32
, encodeWord32, decodeWord32
, encodeInt64, decodeInt64
, encodeWord64, decodeWord64
, encodeFloat', decodeFloat'
, encodeDouble, decodeDouble
, encodeScientific, decodeScientific
, encodeText, decodeText
, encodeLazyText, decodeLazyText
, encodeString, encodeStringOrCrash, decodeString
, encodeByteString, decodeByteString
, encodeLazyByteString, decodeLazyByteString
, encodeDay, decodeDay
, encodeLocalTime, decodeLocalTime
, encodeTimeOfDay, decodeTimeOfDay
, encodeBool, decodeBool
, encodeMaybe, decodeMaybe
) where
import Control.Exception (Exception)
import Data.ByteString (ByteString)
import qualified Data.ByteString.Lazy as LazyByteString
import Data.Int (Int16, Int32, Int64, Int8)
import Data.Scientific (Scientific)
import Data.Text (Text)
import qualified Data.Text as Text
import qualified Data.Text.Lazy as LazyText
import Data.Time.Calendar (Day)
import Data.Time.LocalTime (LocalTime, TimeOfDay)
import Data.Word (Word16, Word32, Word64,
Word8)
import Database.MySQL.Protocol.MySQLValue (MySQLValue (..))
-- | The name of the Haskell type a decoder was trying to produce,
-- carried inside 'DecodeError' for error reporting.
newtype ExpectedTypeName = ExpectedTypeName Text
deriving (Show, Eq)
-- | All the ways decoding a single field can fail.
data DecodeError
= DecodeTypeMismatch !ExpectedTypeName !MySQLValue
-- ^ The value's constructor does not match the requested Haskell
-- type, e.g. asking for 'Int32' from a @MySQLText@.
| DecodeUnexpectedNull !ExpectedTypeName
-- ^ The value was @MySQLNull@ but the requested type is not
-- 'Maybe'; wrap the type in 'Maybe' for nullable columns.
deriving (Show, Eq)
instance Exception DecodeError
-- | Convert a single Haskell value to and from a 'MySQLValue'.
--
-- Instances must obey: @'fromField' ('toField' x) == 'Right' x@.
-- (Caveats: nested 'Maybe' collapses through @NULL@, and IEEE @NaN@
-- fails the literal equation; see the module header.)
class Field a where
toField :: a -> MySQLValue
fromField :: MySQLValue -> Either DecodeError a
-- | Build the error for a value whose constructor does not match,
-- mapping @MySQLNull@ to 'DecodeUnexpectedNull' and everything else to
-- 'DecodeTypeMismatch'.
decodeFailure :: ExpectedTypeName -> MySQLValue -> Either DecodeError a
decodeFailure expected MySQLNull = Left (DecodeUnexpectedNull expected)
decodeFailure expected value = Left (DecodeTypeMismatch expected value)
encodeInt8 :: Int8 -> MySQLValue
encodeInt8 = MySQLInt8
decodeInt8 :: MySQLValue -> Either DecodeError Int8
decodeInt8 (MySQLInt8 n) = Right n
decodeInt8 value = decodeFailure (ExpectedTypeName "Int8") value
instance Field Int8 where
toField = encodeInt8
fromField = decodeInt8
encodeWord8 :: Word8 -> MySQLValue
encodeWord8 = MySQLInt8U
decodeWord8 :: MySQLValue -> Either DecodeError Word8
decodeWord8 (MySQLInt8U n) = Right n
decodeWord8 value = decodeFailure (ExpectedTypeName "Word8") value
instance Field Word8 where
toField = encodeWord8
fromField = decodeWord8
encodeInt16 :: Int16 -> MySQLValue
encodeInt16 = MySQLInt16
decodeInt16 :: MySQLValue -> Either DecodeError Int16
decodeInt16 (MySQLInt16 n) = Right n
decodeInt16 value = decodeFailure (ExpectedTypeName "Int16") value
instance Field Int16 where
toField = encodeInt16
fromField = decodeInt16
encodeWord16 :: Word16 -> MySQLValue
encodeWord16 = MySQLInt16U
decodeWord16 :: MySQLValue -> Either DecodeError Word16
decodeWord16 (MySQLInt16U n) = Right n
decodeWord16 value = decodeFailure (ExpectedTypeName "Word16") value
instance Field Word16 where
toField = encodeWord16
fromField = decodeWord16
encodeInt32 :: Int32 -> MySQLValue
encodeInt32 = MySQLInt32
decodeInt32 :: MySQLValue -> Either DecodeError Int32
decodeInt32 (MySQLInt32 n) = Right n
decodeInt32 value = decodeFailure (ExpectedTypeName "Int32") value
instance Field Int32 where
toField = encodeInt32
fromField = decodeInt32
encodeWord32 :: Word32 -> MySQLValue
encodeWord32 = MySQLInt32U
decodeWord32 :: MySQLValue -> Either DecodeError Word32
decodeWord32 (MySQLInt32U n) = Right n
decodeWord32 value = decodeFailure (ExpectedTypeName "Word32") value
instance Field Word32 where
toField = encodeWord32
fromField = decodeWord32
encodeInt64 :: Int64 -> MySQLValue
encodeInt64 = MySQLInt64
decodeInt64 :: MySQLValue -> Either DecodeError Int64
decodeInt64 (MySQLInt64 n) = Right n
decodeInt64 value = decodeFailure (ExpectedTypeName "Int64") value
instance Field Int64 where
toField = encodeInt64
fromField = decodeInt64
encodeWord64 :: Word64 -> MySQLValue
encodeWord64 = MySQLInt64U
decodeWord64 :: MySQLValue -> Either DecodeError Word64
decodeWord64 (MySQLInt64U n) = Right n
decodeWord64 value = decodeFailure (ExpectedTypeName "Word64") value
instance Field Word64 where
toField = encodeWord64
fromField = decodeWord64
-- | Named with a prime because "Prelude" already exports
-- 'Prelude.encodeFloat'.
encodeFloat' :: Float -> MySQLValue
encodeFloat' = MySQLFloat
decodeFloat' :: MySQLValue -> Either DecodeError Float
decodeFloat' (MySQLFloat x) = Right x
decodeFloat' value = decodeFailure (ExpectedTypeName "Float") value
instance Field Float where
toField = encodeFloat'
fromField = decodeFloat'
encodeDouble :: Double -> MySQLValue
encodeDouble = MySQLDouble
decodeDouble :: MySQLValue -> Either DecodeError Double
decodeDouble (MySQLDouble x) = Right x
decodeDouble value = decodeFailure (ExpectedTypeName "Double") value
instance Field Double where
toField = encodeDouble
fromField = decodeDouble
encodeScientific :: Scientific -> MySQLValue
encodeScientific = MySQLDecimal
decodeScientific :: MySQLValue -> Either DecodeError Scientific
decodeScientific (MySQLDecimal n) = Right n
decodeScientific value = decodeFailure (ExpectedTypeName "Scientific") value
instance Field Scientific where
toField = encodeScientific
fromField = decodeScientific
-- Decision: 'Text' decodes only from @MySQLText@, not from @MySQLBytes@.
-- A @MySQLBytes@ comes from a binary-collated column and need not be valid
-- UTF-8; guessing an encoding here would be a silent failure waiting to
-- happen. Read binary columns as 'ByteString' and decode explicitly.
encodeText :: Text -> MySQLValue
encodeText = MySQLText
decodeText :: MySQLValue -> Either DecodeError Text
decodeText (MySQLText t) = Right t
decodeText value = decodeFailure (ExpectedTypeName "Text") value
instance Field Text where
toField = encodeText
fromField = decodeText
encodeLazyText :: LazyText.Text -> MySQLValue
encodeLazyText = MySQLText . LazyText.toStrict
decodeLazyText :: MySQLValue -> Either DecodeError LazyText.Text
decodeLazyText (MySQLText t) = Right (LazyText.fromStrict t)
decodeLazyText value = decodeFailure (ExpectedTypeName "lazy Text") value
instance Field LazyText.Text where
toField = encodeLazyText
fromField = decodeLazyText
-- | All the ways encoding a 'String' can fail.
data EncodeStringError
= EncodeStringLoneSurrogate !String
-- ^ The 'String' contained a lone surrogate code point (@\\xD800@
-- to @\\xDFFF@), which is not valid Unicode text and cannot be
-- represented in MySQL's UTF-8 wire format. @Text.pack@ would
-- silently replace it with @U+FFFD@, corrupting the data.
deriving (Show, Eq)
instance Exception EncodeStringError
encodeString :: String -> Either EncodeStringError MySQLValue
encodeString string =
if any isLoneSurrogate string
then Left (EncodeStringLoneSurrogate string)
else Right (MySQLText (Text.pack string))
isLoneSurrogate :: Char -> Bool
isLoneSurrogate character = '\xD800' <= character && character <= '\xDFFF'
-- | Backs the 'Field' 'String' instance: the class fixes
-- @toField :: a -> MySQLValue@, so the 'EncodeStringLoneSurrogate'
-- failure from 'encodeString' crashes here instead of being returned.
encodeStringOrCrash :: String -> MySQLValue
encodeStringOrCrash string = case encodeString string of
Right value -> value
Left (EncodeStringLoneSurrogate offending) ->
error ("Database.MySQL.Field.encodeString: \
\lone surrogate code point in String: " <> show offending
<> ". This String is not valid Unicode, so the bug is \
\in whatever produced it (truncated UTF-16 or bad \
\decoding upstream). Fix that producer, or filter \
\the surrogates out before calling toField.")
decodeString :: MySQLValue -> Either DecodeError String
decodeString (MySQLText t) = Right (Text.unpack t)
decodeString value = decodeFailure (ExpectedTypeName "String") value
instance Field String where
toField = encodeStringOrCrash
fromField = decodeString
encodeByteString :: ByteString -> MySQLValue
encodeByteString = MySQLBytes
decodeByteString :: MySQLValue -> Either DecodeError ByteString
decodeByteString (MySQLBytes bs) = Right bs
decodeByteString value = decodeFailure (ExpectedTypeName "ByteString") value
instance Field ByteString where
toField = encodeByteString
fromField = decodeByteString
encodeLazyByteString :: LazyByteString.ByteString -> MySQLValue
encodeLazyByteString = MySQLBytes . LazyByteString.toStrict
decodeLazyByteString :: MySQLValue -> Either DecodeError LazyByteString.ByteString
decodeLazyByteString (MySQLBytes bs) = Right (LazyByteString.fromStrict bs)
decodeLazyByteString value = decodeFailure (ExpectedTypeName "lazy ByteString") value
instance Field LazyByteString.ByteString where
toField = encodeLazyByteString
fromField = decodeLazyByteString
encodeDay :: Day -> MySQLValue
encodeDay = MySQLDate
decodeDay :: MySQLValue -> Either DecodeError Day
decodeDay (MySQLDate d) = Right d
decodeDay value = decodeFailure (ExpectedTypeName "Day") value
instance Field Day where
toField = encodeDay
fromField = decodeDay
-- Decision: 'LocalTime' decodes from both @MySQLDateTime@ and
-- @MySQLTimeStamp@, since both carry a 'LocalTime' payload and which one
-- arrives depends on the column type, not on the Haskell type. Encoding
-- canonically produces @MySQLDateTime@; the roundtrip law still holds.
encodeLocalTime :: LocalTime -> MySQLValue
encodeLocalTime = MySQLDateTime
decodeLocalTime :: MySQLValue -> Either DecodeError LocalTime
decodeLocalTime (MySQLDateTime t) = Right t
decodeLocalTime (MySQLTimeStamp t) = Right t
decodeLocalTime value = decodeFailure (ExpectedTypeName "LocalTime") value
instance Field LocalTime where
toField = encodeLocalTime
fromField = decodeLocalTime
-- | A 'TimeOfDay' is only the non-negative, less-than-a-day fragment of
-- MySQL's @TIME@ type; a negative @TIME@ value fails to decode. For the
-- full range (intervals up to +-838:59:59) match on @MySQLTime@ directly.
encodeTimeOfDay :: TimeOfDay -> MySQLValue
encodeTimeOfDay = MySQLTime 0
decodeTimeOfDay :: MySQLValue -> Either DecodeError TimeOfDay
decodeTimeOfDay (MySQLTime sign t) =
if sign == 0
then Right t
else decodeFailure (ExpectedTypeName "TimeOfDay") (MySQLTime sign t)
decodeTimeOfDay value = decodeFailure (ExpectedTypeName "TimeOfDay") value
instance Field TimeOfDay where
toField = encodeTimeOfDay
fromField = decodeTimeOfDay
-- Decision: 'Bool' maps to @TINYINT@, mirroring MySQL itself where
-- @BOOLEAN@ is an alias for @TINYINT(1)@ and any nonzero value is true.
-- Both the signed and unsigned tiny constructors are accepted because the
-- column may be declared either way.
encodeBool :: Bool -> MySQLValue
encodeBool True = MySQLInt8 1
encodeBool False = MySQLInt8 0
decodeBool :: MySQLValue -> Either DecodeError Bool
decodeBool (MySQLInt8 n) = Right (n /= 0)
decodeBool (MySQLInt8U n) = Right (n /= 0)
decodeBool value = decodeFailure (ExpectedTypeName "Bool") value
instance Field Bool where
toField = encodeBool
fromField = decodeBool
encodeMaybe :: Field a => Maybe a -> MySQLValue
encodeMaybe Nothing = MySQLNull
encodeMaybe (Just value) = toField value
decodeMaybe :: Field a => MySQLValue -> Either DecodeError (Maybe a)
decodeMaybe MySQLNull = Right Nothing
decodeMaybe value = fmap Just (fromField value)
-- | @NULL@ maps to 'Nothing'; use this instance for nullable columns.
-- Do not nest it: SQL cannot distinguish @Just NULL@ from @NULL@, so
-- @Just Nothing@ decodes back as 'Nothing' and the roundtrip law breaks.
instance Field a => Field (Maybe a) where
toField = encodeMaybe
fromField = decodeMaybe
-- | The identity instance, an escape hatch for code that wants to handle
-- the raw protocol value in an otherwise 'Field'-polymorphic setting.
instance Field MySQLValue where
toField = id
fromField = Right