修复bug
This commit is contained in:
1 parent
fc9bbc20e4
commit
e87c4ee6a8
90 files changed
+3828
-452
No files matched your search
+26
-10
@@ -2,6 +2,7 @@
|
||||
|
||||
module Main (main) where
|
||||
|
||||
import ChuSQL.CLI.Normalization (runNormalizationOnce)
|
||||
import ChuSQL.CLI.Format (OutputFormat (..), formatName, parseFormat)
|
||||
import ChuSQL.CLI.Password (readPasswordInput)
|
||||
import ChuSQL.CLI.Script (stripTerminator)
|
||||
@@ -19,7 +20,7 @@ import ChuSQL.Interface.Sudo (SudoCredential, readSudoCredential)
|
||||
import ChuSQL.Interface.TOML (resolveConfigPath)
|
||||
import Control.Exception (finally)
|
||||
import Control.Monad (unless)
|
||||
import Data.IORef (newIORef)
|
||||
import Data.IORef (IORef, newIORef)
|
||||
import Data.Maybe (fromMaybe)
|
||||
import Data.Text (Text)
|
||||
import qualified Data.Text as T
|
||||
@@ -40,6 +41,8 @@ data Options = Options
|
||||
, optExecute :: Maybe Text
|
||||
, optHistory :: Maybe FilePath
|
||||
, optConfig :: Maybe FilePath
|
||||
, optNormalize :: Maybe Text
|
||||
, optDeclaration :: Maybe FilePath
|
||||
, optSudo :: Bool
|
||||
}
|
||||
|
||||
@@ -64,16 +67,19 @@ optionsParser =
|
||||
<> metavar "FORMAT"
|
||||
<> value FormatTable
|
||||
<> showDefaultWith (T.unpack . formatName)
|
||||
<> help "Output format: table, json or csv"
|
||||
<> help "Output format: table (or text), json or csv"
|
||||
)
|
||||
<*> optional (T.pack <$> strOption (long "execute" <> short 'e' <> metavar "SQL" <> help "Run one statement and exit"))
|
||||
<*> optional (strOption (long "history" <> metavar "FILE" <> help "SQL history file (default ~/.chusql_history)"))
|
||||
<*> optional (strOption (long "config" <> metavar "FILE" <> help "Config file to read (default: the fixed settings.toml under the system config directory)"))
|
||||
<*> optional (T.pack <$> strOption (long "normalize" <> metavar "TABLE" <> help "Analyze normalization using explicit declarations"))
|
||||
<*> optional (strOption (long "declaration" <> metavar "FILE" <> help "UTF-8 JSON normalization declaration"))
|
||||
<*> switch (long "sudo" <> help "Authenticate using the local root or elevated administrator identity")
|
||||
|
||||
-- | 把 --format 的取值解析成格式
|
||||
formatReader :: String -> Either String OutputFormat
|
||||
formatReader raw = maybe (Left "format must be table, json or csv") Right (parseFormat (T.pack raw))
|
||||
formatReader "text" = Right FormatTable
|
||||
formatReader raw = maybe (Left "format must be table, text, json or csv") Right (parseFormat (T.pack raw))
|
||||
|
||||
-- | 命令行界面与帮助
|
||||
parserInfo :: ParserInfo Options
|
||||
@@ -91,6 +97,10 @@ main = do
|
||||
hSetEncoding stdout utf8
|
||||
hSetEncoding stderr utf8
|
||||
options <- execParser parserInfo
|
||||
case (optNormalize options, optDeclaration options, optExecute options, optFormat options) of
|
||||
(Nothing, Nothing, _, _) -> pure ()
|
||||
(Just _, Just _, Nothing, format) | format /= FormatCsv -> pure ()
|
||||
_ -> reportError "--normalize requires --declaration, text/json format and cannot be combined with --execute" >> exitFailure
|
||||
configPath <- resolveConfigPath (optConfig options)
|
||||
base <- loadWebConfigAt configPath
|
||||
config <- loadServerConfigAt configPath
|
||||
@@ -129,13 +139,19 @@ runSession options session = do
|
||||
ready <- selectDatabase session (T.strip (optDatabase options))
|
||||
if not ready
|
||||
then exitFailure
|
||||
else case optExecute options of
|
||||
Just sql -> do
|
||||
ok <- runStatementOnce session (optFormat options) (stripTerminator sql)
|
||||
unless ok exitFailure
|
||||
Nothing -> do
|
||||
historyFile <- resolveHistory (optHistory options)
|
||||
runRepl session formatRef historyFile
|
||||
else case (optNormalize options, optDeclaration options) of
|
||||
(Just table, Just file) -> runNormalizationOnce session table file (optFormat options == FormatJson) >>= \ok -> unless ok exitFailure
|
||||
_ -> runSqlSession options session formatRef
|
||||
|
||||
-- 执行 SQL 或进入交互循环。
|
||||
runSqlSession :: Options -> Session -> IORef OutputFormat -> IO ()
|
||||
runSqlSession options session formatRef = case optExecute options of
|
||||
Just sql -> do
|
||||
ok <- runStatementOnce session (optFormat options) (stripTerminator sql)
|
||||
unless ok exitFailure
|
||||
Nothing -> do
|
||||
historyFile <- resolveHistory (optHistory options)
|
||||
runRepl session formatRef historyFile
|
||||
|
||||
-- | -d 指定了库才切,否则保持未选库
|
||||
selectDatabase :: Session -> Text -> IO Bool
|
||||
|
||||
@@ -6,6 +6,7 @@ module Repl
|
||||
, reportError
|
||||
) where
|
||||
|
||||
import ChuSQL.CLI.Normalization (runNormalizationOnce)
|
||||
import ChuSQL.CLI.Format (OutputFormat, formatName, renderResult, renderRows)
|
||||
import ChuSQL.CLI.History (historySettings, rememberStatement)
|
||||
import ChuSQL.CLI.Script (Meta (..), errorHint, metaHelp, parseMeta, takeStatement)
|
||||
@@ -106,6 +107,7 @@ runMeta session formatRef meta = case meta of
|
||||
MetaDatabases -> listDatabases session >> pure True
|
||||
MetaTables -> listTables session >> pure True
|
||||
MetaDescribe name -> describeTable session name >> pure True
|
||||
MetaNormalize table file jsonOutput -> liftIO (runNormalizationOnce session table file jsonOutput) >> pure True
|
||||
MetaRoles -> listRoles session >> pure True
|
||||
MetaConnect name -> do
|
||||
switched <- liftIO (switchDatabase session name)
|
||||
|
||||
@@ -12,6 +12,7 @@ build-type: Simple
|
||||
library
|
||||
hs-source-dirs: src
|
||||
exposed-modules: ChuSQL.CLI.Format
|
||||
ChuSQL.CLI.Normalization
|
||||
ChuSQL.CLI.History
|
||||
ChuSQL.CLI.Password
|
||||
ChuSQL.CLI.Script
|
||||
@@ -55,6 +56,7 @@ test-suite chusql-cli-test
|
||||
, directory
|
||||
, aeson
|
||||
, bytestring
|
||||
, chusql-interface
|
||||
, chusql-cli
|
||||
, chusql-core-model
|
||||
, hspec
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
{-# LANGUAGE OverloadedStrings #-}
|
||||
|
||||
module ChuSQL.CLI.Normalization (normalizationArguments, loadNormalizationDeclaration, renderNormalization, runNormalizationOnce) where
|
||||
|
||||
import ChuSQL.Interface.Normalization
|
||||
import ChuSQL.Interface.Session (Session, sessionDatabase, analyzeNormalization)
|
||||
import Control.Exception (IOException, try)
|
||||
import qualified Data.Aeson as A
|
||||
import qualified Data.Aeson.Key as K
|
||||
import qualified Data.Aeson.KeyMap as KM
|
||||
import qualified Data.ByteString as BS
|
||||
import qualified Data.ByteString.Lazy as BL
|
||||
import Data.Text (Text)
|
||||
import qualified Data.Text as T
|
||||
import qualified Data.Text.Encoding as TE
|
||||
import qualified Data.Text.IO as TIO
|
||||
import System.IO (IOMode (ReadMode), stderr, withBinaryFile)
|
||||
import Text.ParserCombinators.ReadP
|
||||
|
||||
-- CLI 范式入口:读取严格声明文件、发送专用请求并显示报告。
|
||||
|
||||
-- 解析表、声明路径和独立显示格式。
|
||||
normalizationArguments :: Text -> Either Text (Text, FilePath, Bool)
|
||||
normalizationArguments input = case readP_to_S (skipSpaces *> sepBy token skipSpaces <* skipSpaces <* eof) (T.unpack input) of
|
||||
[(parts, "")] -> case parts of
|
||||
[table, "--declaration", file] -> Right (T.pack table, file, False)
|
||||
[table, "--declaration", file, "--format", "text"] -> Right (T.pack table, file, False)
|
||||
[table, "--declaration", file, "--format", "json"] -> Right (T.pack table, file, True)
|
||||
_ -> Left usage
|
||||
_ -> Left usage
|
||||
where
|
||||
usage = "usage: \\normalize TABLE --declaration FILE [--format text|json]"
|
||||
-- 保留引号内的 Windows 路径及空格。
|
||||
token = quoted '"' <++ quoted '\'' <++ munch1 (\c -> c /= ' ' && c /= '\t' && c /= '\n' && c /= '"' && c /= '\'')
|
||||
-- 读取不带转义解释的引号参数。
|
||||
quoted quote = char quote *> munch (/= quote) <* char quote
|
||||
|
||||
-- 有界读取 UTF-8 声明并拒绝重复字段。
|
||||
loadNormalizationDeclaration :: FilePath -> IO (Either Text NormalizationDeclaration)
|
||||
loadNormalizationDeclaration path = do
|
||||
loaded <- try (withBinaryFile path ReadMode (\handle -> BS.hGet handle 65537)) :: IO (Either IOException BS.ByteString)
|
||||
pure $ case loaded of
|
||||
Left err -> Left (T.pack (show err))
|
||||
Right raw -> do
|
||||
value <- decodeNormalizationJSON (BL.fromStrict raw)
|
||||
case A.fromJSON value of
|
||||
A.Error message -> Left (T.pack message)
|
||||
A.Success declaration -> Right declaration
|
||||
|
||||
-- 保留报告字段并选择文本或 JSON 显示。
|
||||
renderNormalization :: Bool -> A.Value -> Text
|
||||
renderNormalization True = jsonText
|
||||
renderNormalization False = pretty 0
|
||||
|
||||
-- 将 JSON 编码为 UTF-8 文本。
|
||||
jsonText :: A.Value -> Text
|
||||
jsonText = TE.decodeUtf8 . BL.toStrict . A.encode
|
||||
|
||||
-- 缩进展示完整报告且保留所有证据。
|
||||
pretty :: Int -> A.Value -> Text
|
||||
pretty depth (A.Object fields) = T.intercalate "\n" [indent <> K.toText key <> ":\n" <> pretty (depth + 1) value | (key, value) <- KM.toList fields]
|
||||
where indent = T.replicate depth " "
|
||||
pretty depth value = T.replicate depth " " <> jsonText value
|
||||
|
||||
-- 发送声明并让失败决定 CLI 退出码。
|
||||
runNormalizationOnce :: Session -> Text -> FilePath -> Bool -> IO Bool
|
||||
runNormalizationOnce session table path jsonOutput = do
|
||||
declaration <- loadNormalizationDeclaration path
|
||||
database <- sessionDatabase session
|
||||
result <- case declaration of
|
||||
Left message -> pure (Left message)
|
||||
Right d -> analyzeNormalization session (NormalizationRequest 1 database table d Nothing Nothing)
|
||||
case result of
|
||||
Left message -> TIO.hPutStrLn stderr ("chusql: error: " <> message) >> pure False
|
||||
Right analysis -> TIO.putStrLn (renderNormalization jsonOutput analysis) >> pure True
|
||||
@@ -10,6 +10,7 @@ module ChuSQL.CLI.Script
|
||||
, metaHelp
|
||||
) where
|
||||
|
||||
import ChuSQL.CLI.Normalization (normalizationArguments)
|
||||
import ChuSQL.CLI.Format (OutputFormat (..), formatName, parseFormat)
|
||||
import Data.Char (isSpace)
|
||||
import Data.Text (Text)
|
||||
@@ -23,6 +24,7 @@ data Meta
|
||||
| MetaDatabases
|
||||
| MetaTables
|
||||
| MetaDescribe Text
|
||||
| MetaNormalize Text FilePath Bool
|
||||
| MetaRoles
|
||||
| MetaConnect Text
|
||||
| MetaSetFormat OutputFormat
|
||||
@@ -50,6 +52,7 @@ parseMeta raw = do
|
||||
| otherwise -> MetaDescribe argument
|
||||
"dr" -> MetaRoles
|
||||
"roles" -> MetaRoles
|
||||
"normalize" -> either (const (MetaUnknown "normalize")) (\(table, file, jsonOutput) -> MetaNormalize table file jsonOutput) (normalizationArguments argument)
|
||||
"c" -> connectTo argument
|
||||
"connect" -> connectTo argument
|
||||
"use" -> connectTo argument
|
||||
@@ -118,6 +121,7 @@ metaHelp =
|
||||
, " \\l, \\list list databases"
|
||||
, " \\dt, \\d list tables"
|
||||
, " \\d <table> describe a table"
|
||||
, " \\normalize TABLE --declaration FILE [--format text|json]"
|
||||
, " \\dr, \\roles list roles with their grants and members"
|
||||
, " \\c <database> switch database"
|
||||
, " \\f, \\format <fmt> output format: " <> T.intercalate ", " (map formatName [FormatTable, FormatJson, FormatCsv])
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
module Main (main) where
|
||||
|
||||
import ChuSQL.CLI.Normalization
|
||||
import ChuSQL.Interface.Normalization
|
||||
import ChuSQL.CLI.Format (OutputFormat (..), parseFormat, renderCsv, renderJson, renderTable, displayValue)
|
||||
import ChuSQL.CLI.History (historySettings, rememberStatement)
|
||||
import ChuSQL.CLI.Password (readPasswordInput)
|
||||
@@ -31,6 +33,7 @@ main = hspec spec
|
||||
-- | 全部测试用例
|
||||
spec :: Spec
|
||||
spec = do
|
||||
normalizationSpec
|
||||
it "renders typed compound values without losing their wire identity" $ do
|
||||
case resolveType builtinTypes (listType intType) of
|
||||
Left err -> expectationFailure err
|
||||
@@ -188,3 +191,26 @@ sampleResult =
|
||||
, qrTruncated = False
|
||||
, qrDatabase = Nothing
|
||||
}
|
||||
|
||||
|
||||
-- 范式元命令、严格文件与显示格式回归。
|
||||
normalizationSpec :: Spec
|
||||
normalizationSpec = describe "CLI: normalization" $ do
|
||||
it "parses quoted paths without interpreting Windows backslashes" $ do
|
||||
normalizationArguments "users --declaration \"C:\\My Data\\fd.json\" --format json" `shouldBe` Right ("users", "C:\\My Data\\fd.json", True)
|
||||
parseMeta "\\normalize users --declaration deps.json --format text" `shouldBe` Just (MetaNormalize "users" "deps.json" False)
|
||||
normalizationArguments "users --declaration deps.json --format csv" `shouldSatisfy` either (const True) (const False)
|
||||
it "keeps the same report fields in text and JSON output" $ do
|
||||
let report = A.object ["candidate_keys" A..= ([["id"]] :: [[T.Text]]), "conditional" A..= True, "findings" A..= [A.object ["normal_form" A..= ("bcnf" :: T.Text), "verdict" A..= ("insufficient" :: T.Text)]]]
|
||||
A.eitherDecodeStrict (encodeUtf8 (renderNormalization True report)) `shouldBe` Right report
|
||||
renderNormalization False report `shouldSatisfy` T.isInfixOf "candidate_keys"
|
||||
renderNormalization False report `shouldSatisfy` T.isInfixOf "insufficient"
|
||||
it "loads defaults and refuses duplicate JSON fields in declaration files" $ do
|
||||
directory <- getTemporaryDirectory
|
||||
bracket (openTempFile directory "normalization-declaration") (\(path, _) -> removeFile path) $ \(path, handle) -> do
|
||||
hPutStr handle "{}"
|
||||
hClose handle
|
||||
loadNormalizationDeclaration path `shouldReturn` Right (NormalizationDeclaration [] [] False Nothing)
|
||||
BL.writeFile path "{\"atomic_attributes\":true,\"atomic_attributes\":false}"
|
||||
result <- loadNormalizationDeclaration path
|
||||
result `shouldSatisfy` either (T.isInfixOf "duplicate") (const False)
|
||||
Reference in new issue
Block a user