packages feed

hpgsql-0.1.0.0: src/Hpgsql/ParsingInternal.hs

-- |
--
-- This module contains parsers that are helpful to separate SQL statements from each other by finding query boundaries: semi-colons, but not when inside a string or a parenthesised expression, for example.
module Hpgsql.ParsingInternal
  ( parseSql,
    BlockOrNotBlock (..),
    ParsingOpts (..),
    QQExprKind (..),
    blockListText,
    blockText,
    flattenBlocks,
  )
where

import Control.Applicative
  ( optional,
    (<|>),
  )
import Control.Monad
  ( void,
  )
import Data.Attoparsec.Text
  ( Parser,
    char,
    endOfInput,
    many',
    many1,
    peekChar,
    string,
    takeWhile,
    takeWhile1,
  )
import qualified Data.Attoparsec.Text as Parsec
import qualified Data.Char as Char
import Data.List.NonEmpty (NonEmpty (..))
import qualified Data.List.NonEmpty as NE
import Data.Text (Text)
import qualified Data.Text as Text
import Language.Haskell.Meta.Parse (parseExp)
import Prelude hiding (takeWhile)

data BlockOrNotBlock = StaticSql !Text | DollarNumberedArg !Int | QuestionMarkArg | QuasiQuoterExpression !QQExprKind !Text | SemiColon | CommentsOrWhitespace !Text
  deriving stock (Eq, Show)

data QQExprKind = QQInterpolation | QQEmbeddedQuery
  deriving stock (Eq, Show)

data ParsingOpts = AcceptQuestionMarksAsQueryArgs | AcceptOnlyDollarNumberedArgs | AcceptQuasiQuoterExpressions
  deriving stock (Show)

-- | Parses one or more SQL statements (separated by semi-colons).
parseSql :: ParsingOpts -> Text -> [BlockOrNotBlock]
parseSql _ "" = []
parseSql popts str = case Parsec.parseOnly (many' (sqlStatementParser popts True) <* endOfInput) str of
  Right mStatements -> mconcat mStatements
  Left err -> error $ "Bug in hpgsql when parsing SQL: " ++ err

sqlStatementParser :: ParsingOpts -> Bool -> Parser [BlockOrNotBlock]
sqlStatementParser popts isBeginningOfStmt = do
  t1 <-
    (if isBeginningOfStmt then commentOrSpaceParser else fail "No whitespace parsing in the middle")
      <|> (\t -> [StaticSql t | not (Text.null t)])
        <$> takeWhile
          (\c -> not (isPossibleBlockStartingChar popts c) && c /= ';')
  mc <- peekChar
  case mc of
    Nothing -> if null t1 then fail "Nothing to parse" else pure t1
    Just ';' -> do
      void $ char ';'
      pure $ t1 ++ [SemiColon]
    Just _ -> do
      t2 <- many1 (blockParser popts) <|> (\pt -> [[StaticSql pt]]) <$> Parsec.take 1
      -- After reading blocks or just a char, we still need to find a semi-colon
      -- or EOF to get a statement from start to finish!
      t3 <- sqlStatementParser popts False <|> ([] <$ endOfInput)
      pure $ t1 ++ mconcat t2 <> t3
  where
    -- \| Parses 1 or more consecutive white-space or comments
    commentOrSpaceParser :: Parser [BlockOrNotBlock]
    commentOrSpaceParser =
      many1 (CommentsOrWhitespace <$> takeWhile1 Char.isSpace <|> doubleDashComment <|> cStyleComment)

eol :: Parser Text
eol = string "\n" <|> string "\r\n"

blockText :: BlockOrNotBlock -> Text
blockText = \case
  StaticSql t -> t
  CommentsOrWhitespace t -> t
  QuestionMarkArg -> "?"
  DollarNumberedArg n -> "$" <> Text.pack (show n)
  QuasiQuoterExpression QQInterpolation t -> "#{" <> t <> "}"
  QuasiQuoterExpression QQEmbeddedQuery t -> "^{" <> t <> "}"
  SemiColon -> ";"

blockListText :: [BlockOrNotBlock] -> Text
blockListText = Text.concat . map blockText

-- For now, this assumes standard_conforming_strings is always on.
blockParser :: ParsingOpts -> Parser [BlockOrNotBlock]
blockParser popts =
  -- Unicode escaped strings aren't explicitly implemented, but work with the current parser.
  -- Since single quotes can't be an escape character for them (see https://www.postgresql.org/docs/current/sql-syntax-lexical.html),
  -- backslashes will be treated like a regular character - which works for us -, and if other characters are chosen as the escape character,
  -- our parsers will treat those also as regular characters, which should be fine.
  -- This seems fragile, but our tests will error out if changes make this unsupported.
  (: [])
    <$> ( case popts of
            AcceptQuasiQuoterExpressions -> quasiQuoterExpressionParser
            _ -> fail "No quasiquoter expressions"
        )
    <|> (: []) <$> parseStdConformingString
    <|> parenthesisedExpression popts
    <|> (: []) <$> cStyleComment
    <|> (: [])
      <$> ( case popts of
              AcceptOnlyDollarNumberedArgs -> dollarNumberedQueryArgParser
              _ -> fail "No dollar-numbered query args"
          )
    <|> (: []) <$> dollarStringParser
    <|> (: [])
      <$> ( case popts of
              AcceptQuestionMarksAsQueryArgs -> questionMarkQueryArgParser
              _ -> fail "No question marks as query args"
          )
    <|> (: []) <$> doubleDashComment
    <|> (: []) <$> doubleQuotedIdentifier
    <|> (: []) <$> cStyleEscapedString

-- | A character that may be the first of a block. This needs to match parsers in `blockParser`, and is only useful
-- to optimize our parsers by avoiding backtracking through usage of `takeWhile` and similar.
isPossibleBlockStartingChar :: ParsingOpts -> Char -> Bool
isPossibleBlockStartingChar popts c =
  c
    == '('
    || c
      == '-'
    || c
      == '/'
    || c
      == '"'
    || c
      == '$'
    || c
      == '\''
    || c
      == 'E'
    || c
      == '?'
    || ( case popts of
           AcceptQuasiQuoterExpressions -> c == '#' || c == '^'
           _ -> False
       )

quasiQuoterExpressionParser :: Parser BlockOrNotBlock
quasiQuoterExpressionParser = do
  prefix <- string "#{" <|> string "^{"
  let kind = if prefix == "#{" then QQInterpolation else QQEmbeddedQuery
  expr <- findExpressionEnd ""
  pure $ QuasiQuoterExpression kind expr
  where
    -- Scan for '}' left-to-right, trying parseExp at each one.
    -- The first '}' where parseExp succeeds is the expression boundary.
    findExpressionEnd acc = do
      chunk <- takeWhile (/= '}')
      void $ char '}'
      let candidate = acc <> chunk
      case parseExp (Text.unpack candidate) of
        Right _ -> pure candidate
        Left _ -> findExpressionEnd (candidate <> "}")

dollarNumberedQueryArgParser :: Parser BlockOrNotBlock
dollarNumberedQueryArgParser = do
  void $ char '$'
  DollarNumberedArg <$> Parsec.decimal

questionMarkQueryArgParser :: Parser BlockOrNotBlock
questionMarkQueryArgParser = do
  void $ char '?'
  escapedQuestionMark <- optional $ char '?'
  case escapedQuestionMark of
    Just _ -> pure $ StaticSql "?"
    Nothing -> pure QuestionMarkArg

dollarStringParser :: Parser BlockOrNotBlock
dollarStringParser = fmap StaticSql $ do
  void $ char '$'
  b <- takeWhile (/= '$')
  void $ char '$'
  let dollarSep = "$" <> b <> "$"
  rest <- go dollarSep
  pure $ dollarSep <> rest
  where
    go dollarSep = do
      t <- takeWhile (/= '$')
      ending <- optional $ string dollarSep <|> "" <$ endOfInput
      case ending of
        Nothing -> do
          void $ char '$'
          rest <- go dollarSep
          pure $ t <> "$" <> rest
        Just e -> pure $ t <> e

doubleDashComment :: Parser BlockOrNotBlock
doubleDashComment = fmap CommentsOrWhitespace $ do
  begin <- string "--"
  rest <- Parsec.takeWhile (\c -> c /= '\n' && c /= '\r')
  end <- eol <|> "" <$ endOfInput
  pure $ begin <> rest <> end

cStyleComment :: Parser BlockOrNotBlock
cStyleComment = fmap CommentsOrWhitespace $ do
  openComment <- string "/*"
  rest <-
    Parsec.scan
      (1 :: Int, False, False)
      ( \(openCommentCount, hasPartialOpening, hasPartialClosing) c ->
          nothingWhenDone $ case (hasPartialOpening, hasPartialClosing, c) of
            -- Handle slashes in all possible contexts
            (False, False, '/') -> Just (openCommentCount, True, False)
            (False, True, '/') -> Just (openCommentCount - 1, False, False)
            (True, False, '/') -> Just (openCommentCount, True, False)
            -- Handle asterisks in all possible contexts
            (False, False, '*') -> Just (openCommentCount, False, True)
            (True, False, '*') -> Just (openCommentCount + 1, False, False)
            (False, True, '*') -> Just (openCommentCount, False, True)
            -- Handle other characters
            (True, True, _) ->
              error
                "Report this as a bug in codd: C Style comment parser invalid state"
            _ -> Just (openCommentCount, False, False)
      )
  end <- string "/" <|> "" <$ endOfInput -- We are generous with eof and allow even invalid SQL in many places
  pure $ openComment <> rest <> end
  where
    nothingWhenDone (Just (0, _, _)) = Nothing
    nothingWhenDone x = x

parenthesisedExpression :: ParsingOpts -> Parser [BlockOrNotBlock]
parenthesisedExpression popts = do
  openParen <- string "(" <|> fail "No open paren"
  rest <- insideParenParser
  pure $ StaticSql openParen : rest
  where
    insideParenParser :: Parser [BlockOrNotBlock]
    insideParenParser = do
      more <-
        takeWhile
          (\c -> not (isPossibleBlockStartingChar popts c) && c /= ')')
      nextChar <- peekChar
      case nextChar of
        Nothing -> pure [StaticSql more] -- Be gentle with EOF
        Just ')' -> do
          closeParen <- string ")"
          pure [StaticSql more, StaticSql closeParen]
        Just _ -> do
          blocksOrOtherwise <- blockParser popts <|> (\t -> [StaticSql t]) <$> Parsec.take 1
          rest <- insideParenParser -- We're still inside an openParen after parsing a block or a character
          pure $ StaticSql more : blocksOrOtherwise ++ rest

-- | Parses a value using backslash as an escape char for any char that matches
-- the supplied predicate. Does not consume the ending char and RETURNS any
-- backslash escape chars in the result.
-- Use `parseWithEscapeCharProper` to exclude the escape chars from the result.
-- This function is useful if you want the original parsed contents.
parseWithEscapeCharPreserve :: (Char -> Bool) -> Parser Text
parseWithEscapeCharPreserve untilc = do
  cs <- Parsec.takeWhile (\c -> c /= '\\' && not (untilc c))
  nextChar <- peekChar
  case nextChar of
    Just '\\' -> do
      c <- Parsec.take 2
      rest <- parseWithEscapeCharPreserve untilc
      pure $ cs <> c <> rest
    _ -> pure cs

doubleQuotedIdentifier :: Parser BlockOrNotBlock
doubleQuotedIdentifier = fmap StaticSql $ do
  openingQuote <- string "\""
  rest <- parseWithEscapeCharPreserve (== '"')
  ending <- string "\""
  pure $ openingQuote <> rest <> ending

-- | Parses a single quoted NON standard conforming string, i.e. strings that use backslash as an escape character, and are marked
-- by beginning with an `E`. Consecutive simple quotes are also treated as a single quote, just like in std conforming strings.
-- See https://www.postgresql.org/docs/current/sql-syntax-lexical.html
cStyleEscapedString :: Parser BlockOrNotBlock
cStyleEscapedString = fmap StaticSql $ do
  openingChars <- string "E'"
  rest <-
    Parsec.scan
      (False, False)
      ( \(lastCharWasBackslash, lastCharWasSingleQuote) c ->
          case ((lastCharWasBackslash, lastCharWasSingleQuote), c) of
            ((False, False), '\'') -> Just (False, True)
            ((False, True), '\'') -> Just (False, False) -- Two consecutive single quotes are not end of string
            ((False, True), _) -> Nothing -- A single quote not preceded by \ and followed by not-a-single-quote is end of string
            ((False, False), '\\') -> Just (True, False)
            ((False, False), _) -> Just (False, False) -- "regular" character
            ((True, False), _) -> Just (False, False) -- Any character after a backslash must be included in the string
            ((True, True), _) ->
              error
                "Please submit this as a bug report to codd, saying both backslash and single quote were last char in cStyleEscapedString"
      )
  pure $ openingChars <> rest

-- | Parses a single quoted standard conforming string, i.e. strings that use '' as a representation of a single quote, and
-- takes any other character literally.
parseStdConformingString :: Parser BlockOrNotBlock
parseStdConformingString = fmap StaticSql $ do
  openingQuote <- string "'"
  rest <-
    Parsec.scan
      False
      ( \lastCharWasQuote c -> case (lastCharWasQuote, c) of
          (False, '\'') -> Just True
          (True, '\'') -> Just False -- Two consecutive single quotes represent a single quote, not end of string
          (True, _) -> Nothing -- One single quote followed by any other character means that single quote was end of string
          (False, _) -> Just False
      )
  pure $ openingQuote <> rest

flattenBlocks :: [BlockOrNotBlock] -> [BlockOrNotBlock]
flattenBlocks =
  map
    ( \bs@(firstEl :| _) -> case firstEl of
        StaticSql _ -> StaticSql $ blockListText $ NE.toList bs
        CommentsOrWhitespace _ -> CommentsOrWhitespace $ blockListText $ NE.toList bs
        x -> x
    )
    . NE.groupBy
      ( \a b -> case (a, b) of
          (StaticSql _, StaticSql _) -> True
          (CommentsOrWhitespace _, CommentsOrWhitespace _) -> True
          _ -> False
      )
    . filter (\b -> blockText b /= "")