packages feed

mysql-haskell-1.5.0: test/StringLiterals.hs

-- | Text and bytes rendered into the query text as single-quoted string
-- literals, read back the way the server's lexer reads them, with and
-- without NO_BACKSLASH_ESCAPES.
module StringLiterals (tests) where

import           Data.ByteString                    (ByteString)
import qualified Data.ByteString                    as B
import qualified Data.ByteString.Lazy               as L
import           Data.Maybe                         (isJust)
import           Data.Text                          (Text)
import qualified Data.Text                          as T
import qualified Data.Text.Encoding                 as T
import           Data.Word                          (Word8)
import           Database.MySQL.Base
import           Test.QuickCheck                    (Gen, arbitrary, elements,
                                                     forAll, frequency, listOf)
import           Test.Tasty
import           Test.Tasty.HUnit                   (testCase, (@?=))
import           Test.Tasty.QuickCheck              (testProperty, (===))

tests :: TestTree
tests = testGroup "string literals"
    [ testCase "a quote and a backslash are doubled, other characters kept" $
        rendered (MySQLText "it's\n\"C:\\\"") @?= "'it''s\n\"C:\\\\\"'"
    , testGroup "default sql_mode"
        [ testProperty "bytes read back exactly" $ forAll genBytes $ \bytes ->
            readLiteral BackslashEscapes (rendered (MySQLBytes bytes)) === Just bytes
        , testProperty "text reads back exactly" $ forAll genText $ \text ->
            readLiteral BackslashEscapes (rendered (MySQLText text)) === Just (T.encodeUtf8 text)
        ]
    , testGroup "NO_BACKSLASH_ESCAPES"
        [ testProperty "bytes end the literal at its closing quote" $ forAll genBytes $ \bytes ->
            isJust (readLiteral NoBackslashEscapes (rendered (MySQLBytes bytes)))
        , testProperty "text ends the literal at its closing quote" $ forAll genText $ \text ->
            isJust (readLiteral NoBackslashEscapes (rendered (MySQLText text)))
        , testProperty "bytes without a backslash read back exactly" $
            forAll (B.filter (/= backslash) <$> genBytes) $ \bytes ->
                readLiteral NoBackslashEscapes (rendered (MySQLBytes bytes)) === Just bytes
        , testProperty "text without a backslash reads back exactly" $
            forAll (T.filter (/= '\\') <$> genText) $ \text ->
                readLiteral NoBackslashEscapes (rendered (MySQLText text)) === Just (T.encodeUtf8 text)
        ]
    ]

rendered :: MySQLValue -> ByteString
rendered value = L.toStrict (fromQuery (renderParams "?" [value]))

data BackslashMode = BackslashEscapes | NoBackslashEscapes

-- | The value the server reads from a whole single-quoted literal, following
-- <https://dev.mysql.com/doc/refman/8.0/en/string-literals.html>; Nothing
-- when the literal ends before the input does, or not at all. Reading bytes
-- is exact for UTF-8, where a quote or backslash byte never belongs to a
-- longer character.
readLiteral :: BackslashMode -> ByteString -> Maybe ByteString
readLiteral mode literal = case B.unpack literal of
    [] -> Nothing
    open : body -> if open == quote then B.pack <$> readBody mode body else Nothing

readBody :: BackslashMode -> [Word8] -> Maybe [Word8]
readBody mode body = case body of
    [] -> Nothing
    byte : rest -> if
        | byte == quote -> case rest of
            [] -> Just []
            next : afterNext ->
                if next == quote then (quote :) <$> readBody mode afterNext else Nothing
        | byte == backslash -> case mode of
            NoBackslashEscapes -> (backslash :) <$> readBody mode rest
            BackslashEscapes -> case rest of
                [] -> Nothing
                escaped : afterEscaped -> (escapedBytes escaped ++) <$> readBody mode afterEscaped
        | otherwise -> (byte :) <$> readBody mode rest

-- | What a backslash followed by this byte stands for in the default mode.
escapedBytes :: Word8 -> [Word8]
escapedBytes escaped = case lookup escaped escapeSequences of
    Just meaning -> [meaning]
    Nothing -> if escaped == 0x25 || escaped == 0x5F then [backslash, escaped] else [escaped]

-- | @\\0 \\b \\n \\r \\t \\Z@; @\\%@ and @\\_@ keep their backslash, any other
-- escaped byte stands for itself.
escapeSequences :: [(Word8, Word8)]
escapeSequences = [(0x30, 0), (0x62, 8), (0x6E, 10), (0x72, 13), (0x74, 9), (0x5A, 26)]

quote, backslash :: Word8
quote = 0x27
backslash = 0x5C

-- | Half the bytes are ones the escaping or the lexer treats specially.
genBytes :: Gen ByteString
genBytes = B.pack <$> listOf (frequency
    [(1, elements (B.unpack (T.encodeUtf8 (T.pack specialCharacters)))), (1, arbitrary)])

genText :: Gen Text
genText = T.pack <$> listOf (frequency [(1, elements specialCharacters), (1, arbitrary)])

specialCharacters :: String
specialCharacters = "\0\b\t\n\r\SUB\"%'\\_"