mmzk-typeid-0.2.0.0: src/Data/UUID/V7.hs
-- |
-- Module : Data.KindID
-- License : MIT
-- Maintainer : mmzk1526@outlook.com
-- Portability : GHC
--
-- IMPORTANT: In the next major release (breaking change), I will unify the
-- 'UUID' type with the one from the uuid-type package.
--
-- UUIDv7 implementation.
--
-- UUIDv7 is not currently present in the uuid package, therefore I have to
-- make a quick patch of my own.
--
-- Note that since the specification for v7 is not yet finalised, this module's
-- implementation may change in the future according to the potential
-- adjustments in the specification.
module Data.UUID.V7
(
-- * Data type
UUID(..)
, unUUID
-- * UUID generation
, nil
, genUUID
, genUUIDs
-- * Encoding & decoding
, parseString
, parseText
, parseByteString
, toString
, toText
, toByteString
-- * Miscellaneous helpers
, getTime
, getEpochMilli
) where
import Control.Monad
import Control.Monad.Trans.Class
import Control.Monad.Trans.Maybe
import Data.Aeson.Types hiding (String)
import Data.Array
import Data.Binary
import Data.Binary.Get
import Data.Binary.Put
import Data.Bits
import Data.ByteString.Lazy (ByteString)
import qualified Data.ByteString.Lazy as BSL
import Data.IORef
import Data.String
import Data.Text (Text)
import qualified Data.Text as T
import Data.Text.Encoding
import Data.Time.Clock.POSIX
import System.Entropy
import System.IO.Unsafe (unsafePerformIO)
-- | A simple wrapper around a 'ByteString' representing a UUIDv7.
--
-- Note that the 'Show' instance is for debugging purposes only. To pretty-print
-- a 'UUID'v7, use 'toString', 'toText' or 'toByteString'.
--
-- The 'UUID' constructor will be hidden in favour of the 'Binary' instance in
-- the future.
newtype UUID = UUID ByteString
deriving (Eq, Ord, Show)
-- | Deprecated. Use the 'Binary' instance instead.
unUUID :: UUID -> ByteString
unUUID (UUID bs) = bs
{-# INLINE unUUID #-}
{-# DEPRECATED unUUID "Use the 'Binary' instance instead" #-}
instance ToJSON UUID where
toJSON :: UUID -> Value
toJSON = toJSON . toString
{-# INLINE toJSON #-}
instance FromJSON UUID where
parseJSON :: Value -> Parser UUID
parseJSON str = do
s <- parseJSON str
case parseString s of
Nothing -> fail "Invalid UUID"
Just uuid -> pure uuid
{-# INLINE parseJSON #-}
instance ToJSONKey UUID where
toJSONKey :: ToJSONKeyFunction UUID
toJSONKey = toJSONKeyText toText
{-# INLINE toJSONKey #-}
instance FromJSONKey UUID where
fromJSONKey :: FromJSONKeyFunction UUID
fromJSONKey = FromJSONKeyTextParser \t -> case parseText t of
Nothing -> fail "Invalid UUID"
Just uuid -> pure uuid
{-# INLINE fromJSONKey #-}
instance Binary UUID where
put :: UUID -> Put
put (UUID bs) = putLazyByteString bs
{-# INLINE put #-}
get :: Get UUID
get = UUID <$> getLazyByteString 16
{-# INLINE get #-}
-- | Pretty-print a 'UUID'v7.
toString :: UUID -> String
toString (UUID bs)
| BSL.length bs /= 16 = "<INVALID-UUID>"
| otherwise = word16ToHex b0
. word16ToHex b1
. (('-' :) . word16ToHex b2)
. (('-' :) . word16ToHex b3)
. (('-' :) . word16ToHex b4)
. (('-' :) . word16ToHex b5)
. word16ToHex b6
$ word16ToHex b7 ""
where
[b0, b1, b2, b3, b4, b5, b6, b7]
= runGet (replicateM 8 getWord16be) bs
hexTable
= listArray (0, 15) "0123456789abcdef"
word16ToHex w rem
= let (q0, r0) = w `divMod` 16
(q1, r1) = q0 `divMod` 16
(q2, r2) = q1 `divMod` 16
(q3, r3) = q2 `divMod` 16
in hexTable ! r3 : hexTable ! r2 : hexTable ! r1 : hexTable ! r0 : rem
-- | Pretty-print a 'UUID'v7 to strict 'Text'.
toText :: UUID -> Text
toText = T.pack . toString
{-# INLINE toText #-}
-- | Pretty-print a 'UUID'v7 to lazy 'ByteString'.
toByteString :: UUID -> ByteString
toByteString = fromString . toString
{-# INLINE toByteString #-}
-- | Parse a 'UUID'v7 from its 'String' representation.
--
-- The representation is either standard or has no dashes. Does not care about
-- the case of the letters.
parseString :: String -> Maybe UUID
parseString = parseByteString . fromString
{-# INLINE parseString #-}
-- | Parse a 'UUID'v7 from its string representation as a strict 'Text'.
--
-- The representation is either standard or has no dashes. Does not care about
-- the case of the letters.
parseText :: Text -> Maybe UUID
parseText = parseByteString . BSL.fromStrict . encodeUtf8
{-# INLINE parseText #-}
-- | Parse a 'UUID'v7 from its string representation as a lazy 'ByteString'.
--
-- The representation is either standard or has no dashes. Does not care about
-- the case of the letters.
parseByteString :: ByteString -> Maybe UUID
parseByteString bs
| BSL.length bs == 32 = UUID <$> parse False
| BSL.length bs == 36 = UUID <$> parse True
| otherwise = Nothing
where
parse hasDashes = (`runGet` bs) $ runMaybeT do
raw1 <- lift $ replicateM 4 (liftM2 (,) getWord8 getWord8)
seg1 <- hoistMaybe $ mapM readHexPair raw1
when hasDashes checkDash
raw2 <- lift $ replicateM 2 (liftM2 (,) getWord8 getWord8)
seg2 <- hoistMaybe $ mapM readHexPair raw2
when hasDashes checkDash
raw3 <- lift $ replicateM 2 (liftM2 (,) getWord8 getWord8)
seg3 <- hoistMaybe $ mapM readHexPair raw3
when hasDashes checkDash
raw4 <- lift $ replicateM 2 (liftM2 (,) getWord8 getWord8)
seg4 <- hoistMaybe $ mapM readHexPair raw4
when hasDashes checkDash
raw5 <- lift $ replicateM 6 (liftM2 (,) getWord8 getWord8)
seg5 <- hoistMaybe $ mapM readHexPair raw5
pure . runPut . mapM_ putWord8 $ concat [seg1, seg2, seg3, seg4, seg5]
readHex w
| w >= 48 && w <= 57 = Just (w - 48)
| w >= 65 && w <= 70 = Just (w - 55)
| w >= 97 && w <= 102 = Just (w - 87)
| otherwise = Nothing
readHexPair (x, y) = do
x' <- readHex x
y' <- readHex y
pure (x' * 16 + y')
checkDash = do
w <- lift getWord8
guard (w == 45)
-- | The nil 'UUID'v7.
nil :: UUID
nil = UUID $ BSL.replicate 16 0
{-# INLINE nil #-}
-- | Generate a 'UUID'v7.
genUUID :: IO UUID
genUUID = head <$> genUUIDs 1
{-# INLINE genUUID #-}
-- | Generate n 'UUID'v7s.
--
-- It tries its best to generate 'UUID's at the same timestamp, but it may not
-- be possible if we are asking too many 'UUID's at the same time.
--
-- It is guaranteed that the first 32768 'UUID's are generated at the same
-- timestamp.
genUUIDs :: Word16 -> IO [UUID]
genUUIDs 0 = pure []
genUUIDs n = do
timestamp <- getEpochMilli
-- We set the first bit of the entropy to 0 to ensure that there's enough
-- room for incrementing the sequence number.
entropy16 <- (.&. 0x7FFF) <$> getEntropyWord16
-- Calculate the maximum number of slots we can use for the current timestamp
-- before the sequence number overflows.
let getMaxSlots num seqNo = if 0xFFFF - seqNo < num
then (0xFFFF - seqNo, 0xFFFF)
else (num, seqNo + num)
-- Get the sequence number corresponding to the current timestamp and the
-- number of UUIDs we can generate.
(n', seqNo) <- atomicModifyIORef __state__ $ \(ts, seqNo) -> if
| ts < timestamp -> let (n', entropy16') = getMaxSlots n entropy16
in ((timestamp, entropy16'), (n', entropy16 + 1))
| ts > timestamp -> ((ts, seqNo), (0, 0))
| otherwise -> let (n', entropy16') = getMaxSlots n seqNo
in ((timestamp, entropy16'), (n', seqNo + 1))
-- If we can't generate any UUIDs, we try again, hoping that the timestamp
-- has changed.
if n' == 0
then genUUIDs n
else do
uuids <- forM [0..(n' - 1)] $ \curN -> do
entropy64 <- getEntropyWord64
pure . UUID $ runPut do
fillTime timestamp
fillVerAndRandA (seqNo + curN)
fillVarAndRandB (seqNo + curN) entropy64
if n' == n
then pure uuids
else (uuids ++) <$> genUUIDs (n - n')
-- | Get the current time in milliseconds since the Unix epoch.
getEpochMilli :: IO Word64
getEpochMilli = do
t <- getPOSIXTime
pure $ round $ t * 1000
{-# INLINE getEpochMilli #-}
-- | Get the time field (unix_ts_ms) of a 'UUID'v7.
getTime :: UUID -> Word64
getTime (UUID bs) = runGet getWord64be bs `shiftR` 16
{-# INLINE getTime #-}
-- | The global mutable state of (timestamp, sequence number).
--
-- The "NOINLINE" pragma is IMPORTANT! The logic would be flawed if it is
-- is inlined by its definition.
__state__ :: IORef (Word64, Word16)
__state__ = unsafePerformIO (newIORef (0, 0))
{-# NOINLINE __state__ #-}
-- | Fill in the 48-bit time field (unix_ts_ms) of a 'UUID'v7 with the given
-- time.
fillTime :: Word64 -> Put
fillTime timestamp = do
let (_, p2, p1, p0) = splitWord64ToWord16s timestamp
mapM_ putWord16be [p2, p1, p0]
{-# INLINE fillTime #-}
-- | Fill in the version and rand_a part of a 'UUID'v7 with the given sequence
-- number.
--
-- The sequence number is a 16-bit integer, of which the first 12 bits are used
-- here in rand_a, and the last 4 bits are used in rand_b. The version is 7.
fillVerAndRandA :: Word16 -> Put
fillVerAndRandA seqNo = do
let seqNoRandA = seqNo `shiftR` 4
let randAWithVer = seqNoRandA .|. (0x7 `shiftL` 12)
putWord16be randAWithVer
{-# INLINE fillVerAndRandA #-}
-- | Fill in the variant and rand_b part of a 'UUID'v7 with the given sequence
-- number and random number. The variant is 2.
--
-- The sequence number is a 16-bit integer, of which the last 4 bits are used
-- here in rand_b while the first 12 bits are used in rand_a.
--
-- The random number is a 64-bit integer of which the last 58 bits are used
-- while the rest are replaced by the variant bits and the last 4 bits of the
-- sequence number.
fillVarAndRandB :: Word16 -> Word64 -> Put
fillVarAndRandB seqNo entropy = do
let seqNoRandB = seqNo .&. 0xF
let randBWithVar = fromIntegral (seqNoRandB .|. (0x2 `shiftL` 4))
putWord64be $ (entropy .&. 0x3FFFFFFFFFFFFFF) .|. (randBWithVar `shiftL` 58)
{-# INLINE fillVarAndRandB #-}
splitWord64ToWord16s :: Word64 -> (Word16, Word16, Word16, Word16)
splitWord64ToWord16s n =
let b0 = fromIntegral (n .&. 0xFFFF)
b1 = fromIntegral ((n `shiftR` 16) .&. 0xFFFF)
b2 = fromIntegral ((n `shiftR` 32) .&. 0xFFFF)
b3 = fromIntegral ((n `shiftR` 48) .&. 0xFFFF)
in (b3, b2, b1, b0)
{-# INLINE splitWord64ToWord16s #-}
getEntropyWord16 :: IO Word16
getEntropyWord16 = do
bs <- BSL.fromStrict <$> getEntropy 2
pure $ runGet getWord16host bs
{-# INLINE getEntropyWord16 #-}
getEntropyWord64 :: IO Word64
getEntropyWord64 = do
bs <- BSL.fromStrict <$> getEntropy 8
pure $ runGet getWord64host bs
{-# INLINE getEntropyWord64 #-}