packages feed

mysql-haskell-1.5.0: src/Database/MySQL/Protocol/RawRow.hs

{-# LANGUAGE UnboxedSums   #-}
{-# LANGUAGE UnboxedTuples #-}

{-|
Module      : Database.MySQL.Protocol.RawRow
Description : Rows as their bytes plus the bounds of each field
Copyright   : (c) Jappie Klooster, 2026
License     : BSD3
Maintainer  : hi@jappie.me
Stability   : experimental
Portability : PORTABLE

A 'RawRow' is a row's bytes plus where each of its fields starts and how long
it is, found in one pass over the row. A field can then be read by its column
number without walking the row again, which is what a library decoding rows by
column number needs. "Database.MySQL.Decoder" reads 'RawRow's.
-}
module Database.MySQL.Protocol.RawRow
  ( -- * Protocols
    TextProtocol
  , BinaryProtocol
    -- * Raw rows
  , RawRow
  , rawRowBytes
  , rawRowFieldCount
  , RawField(..)
  , ColumnNumber(..)
  , rawField
    -- * Building raw rows
  , textRawRow
  , BinaryWidth(..)
  , binaryWidth
  , binaryRawRow
    -- * Internal utilities
  , binaryNullMapLength
  , isNullInMap
  ) where

import           Control.Monad.ST                   (ST, runST)
import           Data.Bits                          (testBit, unsafeShiftR,
                                                     (.&.))
import           Data.ByteString                    (ByteString)
import qualified Data.ByteString                    as B
import qualified Data.ByteString.Unsafe             as B
import           Data.Kind                          (Type)
import qualified Data.Vector                        as V
import qualified Data.Vector.Unboxed                as VU
import qualified Data.Vector.Unboxed.Mutable        as VUM
import           Database.MySQL.Protocol.ColumnDef
import           Database.MySQL.Protocol.MySQLValue (ColumnNumber (..),
                                                     RowError (..),
                                                     RowErrorKind (..),
                                                     lengthEncodedInt,
                                                     nextColumn)

-- | Rows of a plain query, whose fields are text.
--
-- @since 1.4.0
data TextProtocol

-- | Rows of a prepared statement, whose fields are binary.
--
-- @since 1.4.0
data BinaryProtocol

-- | A row's bytes and the bounds of each of its fields. The @protocol@,
-- 'TextProtocol' or 'BinaryProtocol', says how the fields are encoded, so a
-- decoder for the other protocol cannot be run on it.
--
-- @since 1.4.0
data RawRow (protocol :: Type) = RawRow
    { rawRowBytes  :: !ByteString
      -- ^ the row packet's body
    , rawRowBounds :: !(VU.Vector Int)
      -- ^ field @i@ starts at element @2i@ and its length is element @2i + 1@,
      -- or 'nullFieldLength' for a NULL
    }

-- | @since 1.4.0
rawRowFieldCount :: RawRow protocol -> Int
rawRowFieldCount row = VU.length (rawRowBounds row) `unsafeShiftR` 1

-- | One field of a 'RawRow'.
--
-- @since 1.4.0
data RawField
    = RawNull
    | RawBytes !ByteString
      -- ^ the field's bytes, without a length prefix: the value as text in
      -- 'TextProtocol', its binary encoding in 'BinaryProtocol'
    | RawAbsent
      -- ^ the row has no field with that column number
    deriving (Show, Eq)

-- | The field of a column, counting from 0. Inlined so that a caller matching
-- on the result never allocates the 'RawField'.
--
-- @since 1.4.0
rawField :: RawRow protocol -> ColumnNumber -> RawField
rawField row@(RawRow bytes bounds) (ColumnNumber column) =
    -- Compared with the field count, not @2 * column + 1@ with the bounds'
    -- length: doubling a large column number overflows and passes the check.
    if column < 0 || column >= rawRowFieldCount row
    then RawAbsent
    else
        let start       = VU.unsafeIndex bounds (2 * column)
            fieldLength = VU.unsafeIndex bounds (2 * column + 1)
        in if fieldLength == nullFieldLength
           then RawNull
           else RawBytes (B.unsafeTake fieldLength (B.unsafeDrop start bytes))
{-# INLINE rawField #-}

nullFieldLength :: Int
nullFieldLength = -1

-- | The bounds of the first @columnCount@ fields of a text-protocol row. Bytes
-- after the last field are ignored, as 'decodeTextRow' ignores them.
--
-- @since 1.4.0
textRawRow :: Int -> ByteString -> Either RowError (RawRow TextProtocol)
textRawRow columnCount row = runST $ do
    bounds <- VUM.unsafeNew (2 * columnCount)
    outcome <- fillTextBounds row bounds columnCount (ColumnNumber 0) 0
    case outcome of
        Left rowError -> pure (Left rowError)
        Right ()      -> Right . RawRow row <$> VU.unsafeFreeze bounds

-- | Records the bounds of the fields from @column@ on, the first of which
-- starts at byte @offset@.
fillTextBounds :: ByteString -> VUM.MVector s Int -> Int -> ColumnNumber -> Int -> ST s (Either RowError ())
fillTextBounds row bounds columnCount column@(ColumnNumber number) offset =
    if  | number >= columnCount -> pure (Right ())
        | offset >= B.length row -> pure (Left (RowError column offset RowEndsEarly))
        | B.unsafeIndex row offset == 0xFB -> do
            writeBounds bounds column offset nullFieldLength
            fillTextBounds row bounds columnCount (nextColumn column) (offset + 1)
        | otherwise -> case lengthEncodedInt row offset of
            (# kind | #) -> pure (Left (RowError column offset kind))
            (# | (# fieldLength, fieldStart #) #) ->
                if fieldLength > B.length row - fieldStart
                then pure (Left (RowError column offset RowEndsEarly))
                else do
                    writeBounds bounds column fieldStart fieldLength
                    fillTextBounds row bounds columnCount (nextColumn column) (fieldStart + fieldLength)

writeBounds :: VUM.MVector s Int -> ColumnNumber -> Int -> Int -> ST s ()
writeBounds bounds (ColumnNumber column) start fieldLength = do
    VUM.unsafeWrite bounds (2 * column) start
    VUM.unsafeWrite bounds (2 * column + 1) fieldLength

-- | How the binary protocol lays out the non-NULL fields of a column.
--
-- @since 1.4.0
data BinaryWidth
    = BinaryFixed !Int
      -- ^ always this many bytes: the integers, FLOAT, DOUBLE and YEAR
    | BinaryLengthEncoded
      -- ^ a length, then that many bytes: strings, DECIMAL, BIT, and the dates
      -- and times, whose length says which of their parts are present
    | BinaryNoBytes
      -- ^ a column of type NULL, whose fields the NULL map marks
    deriving (Show, Eq)

-- | @since 1.4.0
binaryWidth :: ColumnDef -> BinaryWidth
binaryWidth column =
    if  | t == mySQLTypeTiny -> BinaryFixed 1
        | t == mySQLTypeShort || t == mySQLTypeYear -> BinaryFixed 2
        | t == mySQLTypeLong || t == mySQLTypeInt24 || t == mySQLTypeFloat -> BinaryFixed 4
        | t == mySQLTypeLongLong || t == mySQLTypeDouble -> BinaryFixed 8
        | t == mySQLTypeNull -> BinaryNoBytes
        | otherwise -> BinaryLengthEncoded
  where
    t = columnType column

-- | The bounds of the fields of a binary-protocol row: a 0x00 header, a NULL
-- map with a bit per column from bit 2 on, then the non-NULL fields, laid out
-- as each column's 'BinaryWidth' says.
--
-- @since 1.4.0
binaryRawRow :: V.Vector BinaryWidth -> ByteString -> Either RowError (RawRow BinaryProtocol)
binaryRawRow widths row =
    let nullMapLength = binaryNullMapLength (V.length widths)
    in if 1 + nullMapLength > B.length row
       then Left (RowError (ColumnNumber 0) 0 RowEndsEarly)
       else runST $ do
           bounds <- VUM.unsafeNew (2 * V.length widths)
           outcome <- fillBinaryBounds row widths bounds (ColumnNumber 0) (1 + nullMapLength)
           case outcome of
               Left rowError -> pure (Left rowError)
               Right ()      -> Right . RawRow row <$> VU.unsafeFreeze bounds

fillBinaryBounds :: ByteString -> V.Vector BinaryWidth -> VUM.MVector s Int -> ColumnNumber -> Int
                 -> ST s (Either RowError ())
fillBinaryBounds row widths bounds column@(ColumnNumber number) offset =
    if  | number >= V.length widths -> pure (Right ())
        | isNullInMap row column -> do
            writeBounds bounds column offset nullFieldLength
            fillBinaryBounds row widths bounds (nextColumn column) offset
        | otherwise -> case V.unsafeIndex widths number of
            BinaryNoBytes -> do
                writeBounds bounds column offset 0
                fillBinaryBounds row widths bounds (nextColumn column) offset
            BinaryFixed width ->
                if width > B.length row - offset
                then pure (Left (RowError column offset RowEndsEarly))
                else do
                    writeBounds bounds column offset width
                    fillBinaryBounds row widths bounds (nextColumn column) (offset + width)
            BinaryLengthEncoded ->
                if offset >= B.length row
                then pure (Left (RowError column offset RowEndsEarly))
                else case lengthEncodedInt row offset of
                    (# kind | #) -> pure (Left (RowError column offset kind))
                    (# | (# fieldLength, fieldStart #) #) ->
                        if fieldLength > B.length row - fieldStart
                        then pure (Left (RowError column offset RowEndsEarly))
                        else do
                            writeBounds bounds column fieldStart fieldLength
                            fillBinaryBounds row widths bounds (nextColumn column) (fieldStart + fieldLength)

-- | The bytes of a binary-protocol row's NULL map, which holds a bit per
-- column from bit 2 on.
binaryNullMapLength :: Int -> Int
binaryNullMapLength columnCount = (columnCount + 7 + 2) `unsafeShiftR` 3

-- | The NULL map starts after the header byte; column @i@ is bit @i + 2@. The
-- row must be long enough to hold the map.
isNullInMap :: ByteString -> ColumnNumber -> Bool
isNullInMap row (ColumnNumber column) =
    testBit (B.unsafeIndex row (1 + ((column + 2) `unsafeShiftR` 3))) ((column + 2) .&. 7)