packages feed

clickhouse-migrate-0.1.0.0: src/ClickHouseMigrate/Internal/SqlEnvelope.hs

-- | Conservative lexical envelope; never a general SQL safety proof.
module ClickHouseMigrate.Internal.SqlEnvelope (validateSql, mentionsIdentifier) where

import ClickHouseMigrate.Error
import ClickHouseMigrate.Prelude
import Data.ByteString qualified as BS
import Data.Char (isAsciiLower, isAsciiUpper, toUpper)
import Data.List (find, isPrefixOf)
import Data.Text qualified as Text
import Data.Text.Encoding qualified as Encoding

data Kind = Word !Text | Identifier !Text | Literal | Symbol !Char
  deriving stock (Eq, Show)

data Token = Token !Int !Kind deriving stock (Eq, Show)

failure :: DefinitionErrorCode -> Maybe Int -> Either DefinitionError a
failure c offset = Left (DefinitionError c ["sql"] offset)

validateSql :: ByteString -> Either DefinitionError ()
validateSql sql = do
  text <- either (const (failure InvalidUtf8 Nothing)) Right (Encoding.decodeUtf8' sql)
  if BS.elem 0 sql then failure NulInSql Nothing else pure ()
  tokens <- scan 0 (Text.unpack text)
  statement <- case break (\(Token _ k) -> k == Symbol ';') tokens of
    (xs, []) -> Right xs
    (xs, [_]) -> Right xs
    (_, Token n _ : _) -> failure MultipleStatements (Just n)
  case statement of
    [] -> failure EmptySql Nothing
    _ -> checkEnvelope statement

scan :: Int -> String -> Either DefinitionError [Token]
scan _ [] = Right []
scan n input@(c : cs)
  | c `elem` [' ', '\t', '\r', '\n', '\f'] = scan (n + 1) cs
  | "--" `isPrefixOf` input || "//" `isPrefixOf` input = lineComment
  | "#!" `isPrefixOf` input || "# " `isPrefixOf` input = lineComment
  | "/*" `isPrefixOf` input = do
      (used, rest) <- block n 1 2 (drop 2 input)
      scan (n + used) rest
  | c == '\'' || c == '"' || c == '`' = do
      (used, value, rest) <- quoted n c 1 [] cs
      let kind = if c == '\'' then Literal else Identifier (Text.pack (map toUpper value))
      (Token n kind :) <$> scan (n + used) rest
  | c == '$' = do
      let (tag, end) = span wordChar cs
      case end of
        '$' : rest | case tag of [] -> True; first : _ -> firstName first -> do
          let delimiter = '$' : tag ++ "$"
          (used, remaining) <- heredoc n delimiter (length delimiter) rest
          (Token n Literal :) <$> scan (n + used) remaining
        _ -> failure UnsupportedSql (Just n)
  | wordChar c =
      let (word, rest) = span wordChar input
       in (Token n (Word (Text.pack (map toUpper word))) :) <$> scan (n + length word) rest
  | c `elem` ("(),.;=+-*/%<>![]:{}?|&^~" :: String) = (Token n (Symbol c) :) <$> scan (n + 1) cs
  | otherwise = failure UnsupportedSql (Just n)
  where
    lineComment = let (comment, rest) = break (`elem` ['\r', '\n']) input in scan (n + length comment) rest

firstName :: Char -> Bool
firstName c = isAsciiLower c || isAsciiUpper c || c == '_'

wordChar :: Char -> Bool
wordChar c = firstName c || (c >= '0' && c <= '9')

block :: Int -> Int -> Int -> String -> Either DefinitionError (Int, String)
block start depth used input
  | "/*" `isPrefixOf` input = block start (depth + 1) (used + 2) (drop 2 input)
  | "*/" `isPrefixOf` input = if depth == 1 then Right (used + 2, drop 2 input) else block start (depth - 1) (used + 2) (drop 2 input)
block start _ _ [] = failure UnterminatedSql (Just start)
block start depth used (_ : rest) = block start depth (used + 1) rest

quoted :: Int -> Char -> Int -> String -> String -> Either DefinitionError (Int, String, String)
quoted start _ _ _ [] = failure UnterminatedSql (Just start)
quoted start quote used acc (c : rest)
  | c == quote = case rest of
      d : remaining | d == quote -> quoted start quote (used + 2) (quote : acc) remaining
      _ -> Right (used + 1, reverse acc, rest)
  | c == '\\' = case rest of
      [] -> failure UnterminatedSql (Just start)
      d : remaining
        | quote == '\'' || d `elem` [quote, '\\'] -> quoted start quote (used + 2) (d : acc) remaining
        -- Unfamiliar quoted-identifier escapes are outside the envelope.
        | otherwise -> failure UnsupportedSql (Just (start + used))
  | otherwise = quoted start quote (used + 1) (c : acc) rest

heredoc :: Int -> String -> Int -> String -> Either DefinitionError (Int, String)
heredoc _start delimiter used input
  | delimiter `isPrefixOf` input = Right (used + length delimiter, drop (length delimiter) input)
heredoc start _ _ [] = failure UnterminatedSql (Just start)
heredoc start delimiter used (_ : rest) = heredoc start delimiter (used + 1) rest

keyword :: Text -> Token -> Bool
keyword word (Token _ kind) = kind == Word word

isName :: Token -> Bool
isName (Token _ (Word w)) = maybe False (firstName . fst) (Text.uncons w)
isName (Token _ (Identifier _)) = True
isName _ = False

-- Objects in the command header must be qualified; deeper expressions remain
-- author obligations. Database DDL itself has a single database name.
qualifiedTarget :: [Token] -> Maybe [Token]
qualifiedTarget (a : Token _ (Symbol '.') : b : rest) | isName a && isName b = Just rest
qualifiedTarget _ = Nothing

skipIf :: [Token] -> [Token]
skipIf (a : b : c : rest) | and [keyword "IF" a, keyword "NOT" b, keyword "EXISTS" c] = rest
skipIf (a : b : rest) | keyword "IF" a && keyword "EXISTS" b = rest
skipIf rest = rest

checkEnvelope :: [Token] -> Either DefinitionError ()
checkEnvelope tokens = do
  case find protected tokens of
    Just (Token n _) -> failure ProtectedSetting (Just n)
    Nothing -> pure ()
  case find forbidden (localViewHeader tokens) of
    Just (Token n _) -> failure UnsupportedSql (Just n)
    Nothing -> pure ()
  if any onCluster (pairs tokens) || quotedSettings tokens then failure UnsupportedSql Nothing else pure ()
  if accepted (localViewHeader tokens) then Right () else failure UnsupportedSql (case tokens of Token n _ : _ -> Just n; _ -> Nothing)
  where
    protected (Token _ (Word w)) = w `elem` protectedNames
    protected (Token _ (Identifier w)) = w `elem` protectedNames
    protected _ = False
    forbidden (Token _ (Word w)) = w `elem` forbiddenWords || "REPLICATED" `Text.isPrefixOf` w
    forbidden (Token _ (Identifier w)) = w `elem` ["DISTRIBUTED", "REMOTE", "REMOTESECURE"] || "REPLICATED" `Text.isPrefixOf` w
    forbidden _ = False
    onCluster (a, b) = keyword "ON" a && keyword "CLUSTER" b
    quotedSettings xs = case dropWhile (not . keyword "SETTINGS") xs of
      [] -> False
      _ : rest -> any quotedAssignment (pairs rest)
    quotedAssignment (Token _ (Identifier _), Token _ (Symbol '=')) = True
    quotedAssignment (Token _ Literal, Token _ (Symbol '=')) = True
    quotedAssignment _ = False

-- Only this exact local view header permits REPLACE. ALTER replacement,
-- CREATE OR REPLACE TABLE and later REPLACE tokens remain excluded.
localViewHeader :: [Token] -> [Token]
localViewHeader (create : orToken : replace : view : rest)
  | and [keyword "CREATE" create, keyword "OR" orToken, keyword "REPLACE" replace, keyword "VIEW" view] = create : view : rest
localViewHeader tokens = tokens

protectedNames :: [Text]
protectedNames = ["ASYNC_INSERT", "MUTATIONS_SYNC", "ALTER_SYNC"]

forbiddenWords :: [Text]
forbiddenWords =
  [ "FORMAT",
    "INFILE",
    "OUTFILE",
    "ASYNC",
    "REFRESH",
    "WATCH",
    "REMOTE",
    "REMOTESECURE",
    "DISTRIBUTED",
    "ATTACH",
    "DETACH",
    "FETCH",
    "FREEZE",
    "UNFREEZE",
    "MOVE",
    "REPLACE",
    "LIVE",
    "WINDOW"
  ]

pairs :: [a] -> [(a, a)]
pairs xs = zip xs (drop 1 xs)

accepted :: [Token] -> Bool
accepted (a : b : rest)
  | keyword "CREATE" a = create (b : rest)
  | keyword "ALTER" a && keyword "TABLE" b = case qualifiedTarget (skipIf rest) of
      Just (action : _) -> any (`keyword` action) ["ADD", "DROP", "CLEAR", "MODIFY", "RENAME", "COMMENT", "MATERIALIZE", "UPDATE", "DELETE", "REMOVE"]
      _ -> False
  | keyword "DROP" a = object b (skipIf rest)
  | keyword "TRUNCATE" a && keyword "TABLE" b = maybe False (const True) (qualifiedTarget (skipIf rest))
  | keyword "EXCHANGE" a && keyword "TABLES" b = case qualifiedTarget rest of
      Just (andToken : second) | keyword "AND" andToken -> qualifiedTarget second == Just []
      _ -> False
  | keyword "RENAME" a && keyword "TABLE" b = maybe False (const True) (qualifiedTarget rest)
  | keyword "INSERT" a && keyword "INTO" b = case qualifiedTarget rest of
      Just body -> any (\t -> keyword "SELECT" t || keyword "VALUES" t) body
      _ -> False
  where
    create (kind : body) | keyword "MATERIALIZED" kind = case body of
      view : target | keyword "VIEW" view -> maybe False (const True) (qualifiedTarget (skipIf target))
      _ -> False
    create (kind : body) = object kind (skipIf body)
    create [] = False
    object kind body
      | keyword "TABLE" kind || keyword "VIEW" kind = maybe False (const True) (qualifiedTarget body)
      | keyword "DATABASE" kind = case body of name : _ -> isName name; _ -> False
      | otherwise = False
accepted _ = False

-- Runtime ledger namespace isolation reuses the SAME literal/comment-aware scan.
-- Quoted compound identifiers are conservatively split at dots. This is an
-- identifier reservation, not a proof about arbitrary deeper SQL expressions.
mentionsIdentifier :: Text -> ByteString -> Either DefinitionError Bool
mentionsIdentifier name sql = do
  text <- either (const (failure InvalidUtf8 Nothing)) Right (Encoding.decodeUtf8' sql)
  tokens <- scan 0 (Text.unpack text)
  let wanted = Text.toUpper name
      matches (Token _ (Word value)) = wanted == value
      matches (Token _ (Identifier value)) = wanted `elem` Text.splitOn "." value
      matches _ = False
  pure (any matches tokens)