sq-0.0.1: lib/Sq/Encoders.hs
module Sq.Encoders
( Encode (..)
, ErrEncode (..)
, encodeRefine
, EncodeDefault (..)
, encodeMaybe
, encodeEither
, encodeSizedIntegral
, encodeBinary
, encodeShow
, encodeAeson
)
where
import Control.Exception.Safe qualified as Ex
import Data.Aeson qualified as Ae
import Data.Aeson.Text qualified as Ae
import Data.Bifunctor
import Data.Binary.Put qualified as Bin
import Data.Bits
import Data.Bool
import Data.ByteString qualified as B
import Data.ByteString.Builder qualified as BB
import Data.ByteString.Lazy qualified as BL
import Data.ByteString.Short qualified as BS
import Data.Coerce
import Data.Functor.Contravariant
import Data.Int
import Data.List qualified as List
import Data.Text qualified as T
import Data.Text.Lazy qualified as TL
import Data.Text.Lazy.Builder qualified as TB
import Data.Time qualified as Time
import Data.Time.Format.ISO8601 qualified as Time
import Data.Void
import Data.Word
import Database.SQLite3 qualified as S
import GHC.Float (float2Double)
import GHC.Stack
import Numeric.Natural
import Sq.Null (Null)
--------------------------------------------------------------------------------
-- | How to encode a single Haskell value of type @a@ into a SQLite value.
newtype Encode a
= -- | Encode a value of type @a@ as 'S.SQLData'.
--
-- Ideally, the type @a@ should be small enough that this function always
-- returns 'Right'. However, that can sometimes be annoying, so we allow
-- this function to fail with 'ErrEncode' if necessary, in which case an
-- 'Sq.ErrInput' exception will be eventually thrown while trying to bind the
-- relevant 'Sq.Input' to a 'Statement'. Why? Because for example, not all
-- 'String's can be safely encoded as a 'S.SQLText' seeing as some
-- non-unicode characters will silently be lost in the conversion. So, we
-- could either not have an 'Encode'r for 'String' at all, which would be
-- annoying, or we could have 'ErrEncode' as we do here in order to safely
-- deal with those obscure corner cases.
Encode (a -> Either ErrEncode S.SQLData)
deriving (Contravariant) via Op (Either ErrEncode S.SQLData)
unEncode :: Encode a -> a -> Either ErrEncode S.SQLData
unEncode = coerce
{-# INLINE unEncode #-}
-- | See v'Encode'.
newtype ErrEncode = ErrEncode Ex.SomeException
deriving stock (Show)
deriving anyclass (Ex.Exception)
--------------------------------------------------------------------------------
-- | Default way to encode a Haskell value of type @a@ into a single
-- SQLite column value.
--
-- If there there exist also a 'Sq.DecodeDefault' instance for @a@, then it
-- must roundtrip with the 'Sq.EncodeDefault' instance for @a@.
class EncodeDefault a where
-- | Default way to encode a Haskell value of type @a@ into a single
-- SQLite column value.
encodeDefault :: (HasCallStack) => Encode a
-- | A convenience function for refining an 'Encode'r through a function that
-- may fail with a 'String' error message. The 'CallStack' is preserved.
--
-- If you need a more sophisticated refinement, use the 'Encode' constructor.
encodeRefine
:: (HasCallStack)
=> (a -> Either String b)
-> Encode b
-> Encode a
encodeRefine f (Encode g) = Encode \a ->
case f a of
Right b -> g b
Left e -> first ErrEncode (Ex.throwString e)
--------------------------------------------------------------------------------
-- Core encodes
-- | Literal 'S.SQLData' 'Encode'.
instance EncodeDefault S.SQLData where
encodeDefault = Encode Right
{-# INLINE encodeDefault #-}
-- | 'S.TextColumn'.
instance EncodeDefault T.Text where
encodeDefault = S.SQLText >$< encodeDefault
{-# INLINE encodeDefault #-}
{- TODO avoid encoding '\000' ?
encodeDefault = Encode \t ->
case T.elem '\000' t of
False -> Right $ S.SQLText t
True ->
first ErrEncode $
Ex.throwString ("Invalid character " <> show '\000' <> " in " <> show t)
-}
-- | 'S.IntegerColumn'.
instance EncodeDefault Int64 where
encodeDefault = S.SQLInteger >$< encodeDefault
{-# INLINE encodeDefault #-}
-- | 'S.FloatColumn'.
instance EncodeDefault Double where
encodeDefault = S.SQLFloat >$< encodeDefault
{-# INLINE encodeDefault #-}
-- | 'S.BlobColumn'.
instance EncodeDefault B.ByteString where
encodeDefault = S.SQLBlob >$< encodeDefault
{-# INLINE encodeDefault #-}
-- | 'S.NullColumn'.
instance EncodeDefault Null where
encodeDefault = const S.SQLNull >$< encodeDefault
{-# INLINE encodeDefault #-}
--------------------------------------------------------------------------------
-- Extra encodes
-- | This is 'absurd'.
instance EncodeDefault Void where
encodeDefault = Encode absurd
-- | See 'encodeMaybe'.
instance (EncodeDefault a) => EncodeDefault (Maybe a) where
encodeDefault = encodeMaybe encodeDefault
{-# INLINE encodeDefault #-}
-- | @a@'s 'S.ColumnType' if 'Just', otherwise 'S.NullColumn'.
encodeMaybe :: Encode a -> Encode (Maybe a)
encodeMaybe (Encode f) = Encode $ maybe (Right S.SQLNull) f
{-# INLINE encodeMaybe #-}
-- | See 'encodeEither'.
instance
(EncodeDefault a, EncodeDefault b)
=> EncodeDefault (Either a b)
where
encodeDefault = encodeEither encodeDefault encodeDefault
-- | @a@'s 'S.ColumnType' if 'Left', otherwise @b@'s 'S.ColumnType'.
encodeEither :: Encode a -> Encode b -> Encode (Either a b)
encodeEither (Encode fa) (Encode fb) = Encode $ either fa fb
{-# INLINE encodeEither #-}
-- | 'S.IntegerColumn'. Encodes 'False' as @0@ and 'True' as @1@.
instance EncodeDefault Bool where
encodeDefault = bool 0 1 >$< encodeDefault @Int64
{-# INLINE encodeDefault #-}
-- | 'S.FloatColumn'.
instance EncodeDefault Float where
encodeDefault = float2Double >$< encodeDefault
{-# INLINE encodeDefault #-}
-- | 'S.IntegerColumn'.
instance EncodeDefault Int8 where
encodeDefault = fromIntegral >$< encodeDefault @Int64
{-# INLINE encodeDefault #-}
-- | 'S.IntegerColumn'.
instance EncodeDefault Word8 where
encodeDefault = fromIntegral >$< encodeDefault @Int64
{-# INLINE encodeDefault #-}
-- | 'S.IntegerColumn'.
instance EncodeDefault Int16 where
encodeDefault = fromIntegral >$< encodeDefault @Int64
{-# INLINE encodeDefault #-}
-- | 'S.IntegerColumn'.
instance EncodeDefault Word16 where
encodeDefault = fromIntegral >$< encodeDefault @Int64
{-# INLINE encodeDefault #-}
-- | 'S.IntegerColumn'.
instance EncodeDefault Int32 where
encodeDefault = fromIntegral >$< encodeDefault @Int64
{-# INLINE encodeDefault #-}
-- | 'S.IntegerColumn'.
instance EncodeDefault Word32 where
encodeDefault = fromIntegral >$< encodeDefault @Int64
{-# INLINE encodeDefault #-}
-- | 'S.IntegerColumn'.
instance EncodeDefault Int where
encodeDefault = fromIntegral >$< encodeDefault @Int64
{-# INLINE encodeDefault #-}
-- | 'S.IntegerColumn' if it fits in 'Int64', otherwise 'S.TextColumn'.
instance EncodeDefault Word where
encodeDefault = encodeSizedIntegral
{-# INLINE encodeDefault #-}
-- | 'S.IntegerColumn' if it fits in 'Int64', otherwise 'S.TextColumn'.
instance EncodeDefault Word64 where
encodeDefault = encodeSizedIntegral
{-# INLINE encodeDefault #-}
-- | 'S.IntegerColumn' if it fits in 'Int64', otherwise 'S.TextColumn'.
instance EncodeDefault Integer where
encodeDefault = encodeSizedIntegral
{-# INLINE encodeDefault #-}
-- | 'S.IntegerColumn' if it fits in 'Int64', otherwise 'S.TextColumn'.
instance EncodeDefault Natural where
encodeDefault = encodeSizedIntegral
{-# INLINE encodeDefault #-}
-- | 'S.IntegerColumn' if it fits in 'Int64', otherwise 'S.TextColumn'.
encodeSizedIntegral :: (Integral a, Bits a, HasCallStack) => Encode a
encodeSizedIntegral = Encode \a ->
case toIntegralSized a of
Just i -> unEncode (encodeDefault @Int64) i
Nothing -> unEncode (encodeDefault @String) (show (toInteger a))
-- | 'S.TextColumn'.
instance EncodeDefault TL.Text where
encodeDefault = TL.toStrict >$< encodeDefault
{-# INLINE encodeDefault #-}
-- | 'S.TextColumn'.
instance EncodeDefault TB.Builder where
encodeDefault = TB.toLazyText >$< encodeDefault
{-# INLINE encodeDefault #-}
-- | 'S.TextColumn'.
instance EncodeDefault Char where
encodeDefault = pure >$< encodeDefault @String
{-# INLINE encodeDefault #-}
-- | 'S.TextColumn'.
instance EncodeDefault String where
encodeDefault = Encode \xc ->
case List.find invalid xc of
Nothing -> unEncode encodeDefault (T.pack xc)
Just c ->
first ErrEncode $
Ex.throwString ("Invalid character " <> show c)
where
invalid :: Char -> Bool
invalid = \c -> '\55296' <= c && c <= '\57343'
-- | 'S.BlobColumn'.
instance EncodeDefault BL.ByteString where
encodeDefault = BL.toStrict >$< encodeDefault
{-# INLINE encodeDefault #-}
-- | 'S.BlobColumn'.
instance EncodeDefault BS.ShortByteString where
encodeDefault = BS.fromShort >$< encodeDefault
{-# INLINE encodeDefault #-}
-- | 'S.BlobColumn'.
instance EncodeDefault BB.Builder where
encodeDefault = BB.toLazyByteString >$< encodeDefault
{-# INLINE encodeDefault #-}
-- | 'Time.ISO8601' in a @'S.TextColumn'.
--
-- @yyyy-mm-ddThh:mm:ss/[.ssssssssssss]/+00:00@
--
-- * Sorting these lexicographically in SQL corresponds to sorting them by time.
--
-- * __WARNING__: SQLite date and time functions support resolution only up to
-- milliseconds.
--
-- * __WARNING__: SQLite date and time functions don't support leap seconds.
instance EncodeDefault Time.UTCTime where
encodeDefault = Time.utcToZonedTime Time.utc >$< encodeDefault
-- | 'Time.ISO8601' in a @'S.TextColumn'.
--
-- @yyyy-mm-ddThh:mm:ss/[.ssssssssssss]/±hh:mm@
--
-- * __WARNING__: Sorting these lexicographically in SQL won't work unless the
-- offset is always the same! Convert to 'Time.UTCTime' first.
--
-- * __WARNING__: SQLite date and time functions support resolution only up to
-- milliseconds.
--
-- * __WARNING__: SQLite date and time functions don't support leap seconds.
instance EncodeDefault Time.ZonedTime where
encodeDefault = Time.iso8601Show >$< encodeDefault
-- | 'Time.ISO8601' in a @'S.TextColumn'.
--
-- @yyyy-mm-ddThh:mm:ss/[.ssssssssssss]/@
--
-- * Sorting these lexicographically in SQL corresponds to sorting them by time.
--
-- * __WARNING__: SQLite date and time functions support resolution only up to
-- milliseconds.
--
-- * __WARNING__: SQLite date and time functions don't support leap seconds.
instance EncodeDefault Time.LocalTime where
encodeDefault = Time.iso8601Show >$< encodeDefault
-- | ISO-8601 in a @'S.TextColumn'.
--
-- @yyyy-mm-dd@
--
-- * Sorting these lexicographically in SQL corresponds to sorting them by time.
instance EncodeDefault Time.Day where
encodeDefault = Time.iso8601Show >$< encodeDefault
-- | 'Time.ISO8601' in a @'S.TextColumn'.
--
-- @hh:mm:ss/[.ssssssssssss]/@
--
-- * Sorting these lexicographically in SQL corresponds to sorting them by time.
--
-- * __WARNING__: SQLite date and time functions support resolution only up to
-- milliseconds.
--
-- * __WARNING__: SQLite date and time functions don't support leap seconds.
instance EncodeDefault Time.TimeOfDay where
encodeDefault = Time.iso8601Show >$< encodeDefault
-- | 'Time.ISO8601' in a @'S.TextColumn'.
--
-- @PyYmMdD@
instance EncodeDefault Time.CalendarDiffDays where
encodeDefault = Time.iso8601Show >$< encodeDefault
-- | 'Time.ISO8601' in a @'S.TextColumn'.
--
-- @PyYmMdDThHmMs/[.ssssssssssss]/S@
--
-- * __WARNING__: SQLite date and time functions support resolution only up to
-- milliseconds.
instance EncodeDefault Time.CalendarDiffTime where
encodeDefault = Time.iso8601Show >$< encodeDefault
-- | 'Time.ISO8601' in a @'S.TextColumn'.
--
-- @±hh:mm@
instance EncodeDefault Time.TimeZone where
encodeDefault = Time.iso8601Show >$< encodeDefault
--------------------------------------------------------------------------------
-- | 'S.BlobColumn'.
encodeBinary :: (a -> Bin.Put) -> Encode a
encodeBinary f = contramap (Bin.runPut . f) (encodeDefault @BL.ByteString)
{-# INLINE encodeBinary #-}
-- | 'S.TextColumn'.
encodeShow :: (Show a) => Encode a
encodeShow = show >$< (encodeDefault @String)
{-# INLINE encodeShow #-}
-- | Encodes as 'S.TextColumn'.
encodeAeson :: (a -> Ae.Value) -> Encode a
encodeAeson f = contramap (Ae.encodeToLazyText . f) (encodeDefault @TL.Text)
{-# INLINE encodeAeson #-}