修复错误,优化性能,实现类型系统,优化安装卸载过程
This commit is contained in:
1 parent
fd5f320360
commit
48eec484b7
104 files changed
+4127
-845
No files matched your search
+15
-9
@@ -3,8 +3,9 @@
|
||||
module Main (main) where
|
||||
|
||||
import ChuSQL.CLI.Format (OutputFormat (..), formatName, parseFormat)
|
||||
import ChuSQL.CLI.Password (readPasswordInput)
|
||||
import ChuSQL.CLI.Script (stripTerminator)
|
||||
import ChuSQL.CLI.Session (
|
||||
import ChuSQL.Interface.Session (
|
||||
Session,
|
||||
authenticateSession,
|
||||
isPlainIdentifier,
|
||||
@@ -13,7 +14,6 @@ import ChuSQL.CLI.Session (
|
||||
)
|
||||
import ChuSQL.Interface.Config (ServerConfig (..), loadServerConfigAt, loadWebConfigAt, rootUserName)
|
||||
import ChuSQL.Interface.Link (Client, closeClient, connectClient)
|
||||
import ChuSQL.Interface.Settings (readSettingsFile)
|
||||
import ChuSQL.Interface.TOML (resolveConfigPath)
|
||||
import Control.Exception (finally)
|
||||
import Control.Monad (unless)
|
||||
@@ -27,7 +27,7 @@ import System.Console.Haskeline (defaultSettings, getPassword, runInputT)
|
||||
import System.Directory (getHomeDirectory)
|
||||
import System.Exit (exitFailure)
|
||||
import System.FilePath ((</>))
|
||||
import System.IO (hSetEncoding, stderr, stdout, utf8)
|
||||
import System.IO (hFlush, hIsTerminalDevice, hPutStr, hSetEncoding, stderr, stdin, stdout, utf8)
|
||||
|
||||
-- chusql-cli:与 Web 管理端并列的前端,接到拥有存储的 server 上跑 SQL,账号与 Web 共用。
|
||||
|
||||
@@ -44,7 +44,7 @@ data Options = Options
|
||||
optionsParser :: Parser Options
|
||||
optionsParser =
|
||||
Options
|
||||
<$> optional (T.pack <$> strOption (long "user" <> short 'u' <> metavar "USER" <> help "Sign in as this account (default: the configured administrator)"))
|
||||
<$> optional (T.pack <$> strOption (long "user" <> short 'u' <> metavar "USER" <> help "Sign in as this account (default: root)"))
|
||||
<*> ( T.pack
|
||||
<$> strOption
|
||||
( long "database"
|
||||
@@ -65,7 +65,7 @@ optionsParser =
|
||||
)
|
||||
<*> 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 chusql.toml under the system config directory)"))
|
||||
<*> optional (strOption (long "config" <> metavar "FILE" <> help "Config file to read (default: the fixed settings.toml under the system config directory)"))
|
||||
|
||||
-- | 把 --format 的取值解析成格式
|
||||
formatReader :: String -> Either String OutputFormat
|
||||
@@ -89,9 +89,8 @@ main = do
|
||||
options <- execParser parserInfo
|
||||
configPath <- resolveConfigPath (optConfig options)
|
||||
base <- loadWebConfigAt configPath
|
||||
saved <- readSettingsFile configPath
|
||||
config <- loadServerConfigAt configPath
|
||||
let rootName = rootUserName base saved
|
||||
let rootName = rootUserName base
|
||||
userName = fromMaybe rootName (optUser options)
|
||||
password <- promptPassword userName
|
||||
connected <- connectClient (T.pack (scHost config)) (scPort config)
|
||||
@@ -139,8 +138,15 @@ selectDatabase session wanted
|
||||
-- | 交互式读取口令;直接回车表示空口令
|
||||
promptPassword :: Text -> IO Text
|
||||
promptPassword user = do
|
||||
entered <- runInputT defaultSettings (getPassword (Just '*') (T.unpack user <> "'s password: "))
|
||||
pure (maybe "" T.pack entered)
|
||||
terminal <- hIsTerminalDevice stdin
|
||||
if terminal
|
||||
then do
|
||||
entered <- runInputT defaultSettings (getPassword (Just '*') (T.unpack user <> "'s password: "))
|
||||
pure (maybe "" T.pack entered)
|
||||
else do
|
||||
hPutStr stderr (T.unpack user <> "'s password: ")
|
||||
hFlush stderr
|
||||
readPasswordInput stdin
|
||||
|
||||
-- | 取历史文件路径,没给就用默认
|
||||
resolveHistory :: Maybe FilePath -> IO (Maybe FilePath)
|
||||
|
||||
@@ -7,8 +7,9 @@ module Repl
|
||||
) where
|
||||
|
||||
import ChuSQL.CLI.Format (OutputFormat, formatName, renderResult, renderRows)
|
||||
import ChuSQL.CLI.History (historySettings, rememberStatement)
|
||||
import ChuSQL.CLI.Script (Meta (..), errorHint, metaHelp, parseMeta, takeStatement)
|
||||
import ChuSQL.CLI.Session (Session, catalog, databases, roleViews, runStatement, sessionDatabase, switchDatabase)
|
||||
import ChuSQL.Interface.Session (Session, catalog, databases, roleViews, runStatement, sessionDatabase, switchDatabase)
|
||||
import ChuSQL.Core.Protocol (SchemaColumn (..), TableInfo (..))
|
||||
import ChuSQL.Interface.Protocol (Grant (..), RoleView (..))
|
||||
import Control.Monad (forM_, when)
|
||||
@@ -25,7 +26,7 @@ import System.IO (hIsTerminalDevice, stderr, stdin)
|
||||
-- | 交互循环,没有历史文件就不落盘
|
||||
runRepl :: Session -> IORef OutputFormat -> Maybe FilePath -> IO ()
|
||||
runRepl session formatRef historyPath =
|
||||
runInputT (defaultSettings{historyFile = historyPath}) (loop session formatRef T.empty)
|
||||
runInputT (historySettings historyPath) (loop session formatRef T.empty)
|
||||
|
||||
-- | 入口处直接报错(-e 与启动阶段用)
|
||||
reportError :: Text -> IO ()
|
||||
@@ -56,6 +57,7 @@ loop session formatRef buffer = do
|
||||
then loop session formatRef buffer
|
||||
else case (T.null buffer, parseMeta line) of
|
||||
(True, Just meta) -> do
|
||||
rememberStatement line
|
||||
again <- runMeta session formatRef meta
|
||||
when again (loop session formatRef T.empty)
|
||||
_ -> do
|
||||
@@ -81,7 +83,9 @@ drain :: Session -> IORef OutputFormat -> Text -> InputT IO Text
|
||||
drain session formatRef buffer = case takeStatement buffer of
|
||||
Nothing -> pure (T.strip buffer)
|
||||
Just (statement, rest) -> do
|
||||
when (not (T.null statement)) (runOne session formatRef statement)
|
||||
when (not (T.null statement)) $ do
|
||||
rememberStatement (statement <> ";")
|
||||
runOne session formatRef statement
|
||||
drain session formatRef rest
|
||||
|
||||
-- | 执行一条语句并打印结果
|
||||
|
||||
@@ -2,21 +2,25 @@ cabal-version: 2.4
|
||||
|
||||
name: chusql-cli
|
||||
version: 1.0.0
|
||||
synopsis: ChuSQL 命令行客户端:直连数据库服务,与 Web 管理端共用账号
|
||||
description: ChuSQL 的命令行前端。自己不开存储、不链引擎,所有 SQL 与会话
|
||||
都走 chusql-server 的 TCP 端点;账号表与权限服务与 Web 管理端完全共用。
|
||||
synopsis: ChuSQL command-line client for the database server
|
||||
description: Provides a command-line interface for SQL and database sessions
|
||||
through the chusql-server TCP endpoint. Shares accounts and
|
||||
permissions with the web interface while keeping query execution
|
||||
and storage in the database server process.
|
||||
build-type: Simple
|
||||
|
||||
library
|
||||
hs-source-dirs: src
|
||||
exposed-modules: ChuSQL.CLI.Format
|
||||
ChuSQL.CLI.History
|
||||
ChuSQL.CLI.Password
|
||||
ChuSQL.CLI.Script
|
||||
ChuSQL.CLI.Session
|
||||
build-depends: base >= 4.14 && < 5
|
||||
, aeson
|
||||
, bytestring
|
||||
, chusql-core-model
|
||||
, chusql-interface
|
||||
, haskeline
|
||||
, text
|
||||
default-language: Haskell2010
|
||||
ghc-options: -Wall
|
||||
@@ -48,11 +52,13 @@ test-suite chusql-cli-test
|
||||
main-is: Spec.hs
|
||||
hs-source-dirs: test
|
||||
build-depends: base
|
||||
, directory
|
||||
, aeson
|
||||
, bytestring
|
||||
, chusql-cli
|
||||
, chusql-core-model
|
||||
, hspec
|
||||
, haskeline
|
||||
, text
|
||||
default-language: Haskell2010
|
||||
ghc-options: -Wall
|
||||
@@ -0,0 +1,21 @@
|
||||
{-# LANGUAGE OverloadedStrings #-}
|
||||
|
||||
module ChuSQL.CLI.History (historySettings, rememberStatement) where
|
||||
|
||||
import Control.Monad (unless)
|
||||
import Control.Monad.IO.Class (MonadIO)
|
||||
import qualified Data.Text as T
|
||||
import System.Console.Haskeline (InputT, Settings (..), defaultSettings, modifyHistory)
|
||||
import System.Console.Haskeline.History (addHistory)
|
||||
|
||||
-- 关闭逐行历史,筛选完整语句后记录安全输入。
|
||||
|
||||
-- | 配置仅手动追加的输入历史
|
||||
historySettings :: MonadIO m => Maybe FilePath -> Settings m
|
||||
historySettings path = defaultSettings {historyFile = path, autoAddHistory = False}
|
||||
|
||||
-- | 排除含口令标记的完整语句
|
||||
rememberStatement :: MonadIO m => T.Text -> InputT m ()
|
||||
rememberStatement statement =
|
||||
unless (T.null (T.strip statement) || any (`T.isInfixOf` T.toCaseFold statement) ["password", "identified"])
|
||||
(modifyHistory (addHistory (T.unpack statement)))
|
||||
@@ -0,0 +1,14 @@
|
||||
module ChuSQL.CLI.Password (readPasswordInput) where
|
||||
|
||||
import Data.Text (Text)
|
||||
import qualified Data.Text.IO as T
|
||||
import System.IO (Handle, hIsEOF, hSetNewlineMode, universalNewlineMode)
|
||||
|
||||
-- 管道口令输入:识别 LF 与 CRLF 行尾,保留口令内容。
|
||||
|
||||
-- | 从句柄读取一行口令
|
||||
readPasswordInput :: Handle -> IO Text
|
||||
readPasswordInput handle = do
|
||||
hSetNewlineMode handle universalNewlineMode
|
||||
empty <- hIsEOF handle
|
||||
if empty then pure mempty else T.hGetLine handle
|
||||
@@ -1,5 +0,0 @@
|
||||
module ChuSQL.CLI.Session (module ChuSQL.Interface.Session) where
|
||||
|
||||
import ChuSQL.Interface.Session
|
||||
|
||||
-- 命令行会话:转出前端共享的会话接口,一切请求走 server。
|
||||
@@ -8,5 +8,6 @@ packages:
|
||||
- ../chusql-core/engine
|
||||
- ../chusql-server
|
||||
- ../chusql-web
|
||||
- ../chusql-bootstrap
|
||||
- ../chusql-core/model
|
||||
- ../chusql-interface
|
||||
+78
-1
@@ -3,6 +3,8 @@
|
||||
module Main (main) where
|
||||
|
||||
import ChuSQL.CLI.Format (OutputFormat (..), parseFormat, renderCsv, renderJson, renderTable)
|
||||
import ChuSQL.CLI.History (historySettings, rememberStatement)
|
||||
import ChuSQL.CLI.Password (readPasswordInput)
|
||||
import ChuSQL.CLI.Script (Meta (..), errorHint, parseMeta, statementComplete, stripTerminator, takeStatement)
|
||||
import ChuSQL.Core.Model (Value (..))
|
||||
import ChuSQL.Core.Protocol (QueryResult (..), queryResultJson)
|
||||
@@ -11,9 +13,15 @@ import qualified Data.Aeson as A
|
||||
import qualified Data.ByteString.Lazy as BL
|
||||
import qualified Data.Text as T
|
||||
import Data.Text.Encoding (encodeUtf8)
|
||||
import Control.Exception (bracket, finally)
|
||||
import Control.Monad.IO.Class (liftIO)
|
||||
import System.Directory (getTemporaryDirectory, removeFile)
|
||||
import System.IO (Handle, SeekMode (AbsoluteSeek), hClose, hPutStr, hSeek, hSetNewlineMode, noNewlineTranslation, openTempFile)
|
||||
import Test.Hspec
|
||||
import System.Console.Haskeline (getHistory, getInputLine, runInputT, runInputTBehavior, useFileHandle)
|
||||
import System.Console.Haskeline.History (historyLines)
|
||||
|
||||
-- chusql-cli 测试:只覆盖本前端的纯逻辑(输出格式、语句缓冲与元命令)。
|
||||
-- chusql-cli 测试:输出格式、语句缓冲、元命令与口令输入。
|
||||
|
||||
-- | 测试入口
|
||||
main :: IO ()
|
||||
@@ -24,6 +32,75 @@ spec :: Spec
|
||||
spec = do
|
||||
formatSpec
|
||||
scriptSpec
|
||||
passwordSpec
|
||||
historySpec
|
||||
|
||||
-- | 历史过滤与持久化的用例
|
||||
historySpec :: Spec
|
||||
historySpec = describe "CLI: SQL history" $ do
|
||||
it "does not automatically record unfinished input lines" $
|
||||
withPasswordInput "CREATE USER alice IDENTIFIED BY\n'secret'\n" (\handle -> do
|
||||
entries <- runInputTBehavior (useFileHandle handle) (historySettings Nothing) $ do
|
||||
getInputLine "" >>= liftExpectation "CREATE USER alice IDENTIFIED BY"
|
||||
getInputLine "" >>= liftExpectation "'secret'"
|
||||
historyLines <$> getHistory
|
||||
entries `shouldBe` [])
|
||||
it "keeps ordinary statements and excludes multiline or mixed-case passwords" $ do
|
||||
entries <- runInputT (historySettings Nothing) $ do
|
||||
rememberStatement "SELECT 1;"
|
||||
rememberStatement "CREATE USER alice\nWITH PaSsWoRd\n'secret';"
|
||||
rememberStatement "ALTER USER alice PASSWORD 'new-secret';"
|
||||
rememberStatement "CREATE USER alice\nIdEnTiFiEd /* credential */ BY\n'secret';"
|
||||
rememberStatement "ALTER ROLE analyst IDENTIFIED BY 'new-secret';"
|
||||
rememberStatement "SELECT 'password';"
|
||||
rememberStatement " "
|
||||
historyLines <$> getHistory
|
||||
entries `shouldBe` ["SELECT 1;"]
|
||||
it "persists only safe statements from a batch" $ do
|
||||
directory <- getTemporaryDirectory
|
||||
bracket (openTempFile directory "csql-history-test")
|
||||
(\(path, _) -> removeFile path)
|
||||
(\(path, handle) -> do
|
||||
hClose handle
|
||||
runInputT (historySettings (Just path)) $
|
||||
rememberBatch "SELECT 1; CREATE USER alice IDENTIFIED BY 'secret'; SELECT 2;"
|
||||
entries <- runInputT (historySettings (Just path)) (historyLines <$> getHistory)
|
||||
entries `shouldBe` ["SELECT 2;", "SELECT 1;"])
|
||||
where
|
||||
-- | 核对实际读取的输入行
|
||||
liftExpectation expected actual = liftIO (actual `shouldBe` Just expected)
|
||||
-- | 按完整语句筛选批量输入
|
||||
rememberBatch input = case takeStatement input of
|
||||
Nothing -> pure ()
|
||||
Just (statement, rest) -> do
|
||||
rememberStatement (statement <> ";")
|
||||
rememberBatch rest
|
||||
|
||||
-- | 在临时句柄中写入口令输入
|
||||
withPasswordInput :: String -> (Handle -> IO a) -> IO a
|
||||
withPasswordInput input action = do
|
||||
directory <- getTemporaryDirectory
|
||||
bracket (openTempFile directory "csql-password-test")
|
||||
(\(path, handle) -> hClose handle `finally` removeFile path)
|
||||
(\(_, handle) -> do
|
||||
hSetNewlineMode handle noNewlineTranslation
|
||||
hPutStr handle input
|
||||
hSeek handle AbsoluteSeek 0
|
||||
action handle)
|
||||
|
||||
-- | 管道口令的行尾与内容用例
|
||||
passwordSpec :: Spec
|
||||
passwordSpec = describe "CLI: password input" $ do
|
||||
it "reads LF, CRLF and unterminated passwords identically" $ do
|
||||
mapM_ (\input -> withPasswordInput input (\handle -> readPasswordInput handle `shouldReturn` "secret"))
|
||||
["secret\n", "secret\r\n", "secret"]
|
||||
it "preserves password spaces and internal carriage returns" $
|
||||
withPasswordInput " a\rb \r\n" (\handle -> readPasswordInput handle `shouldReturn` " a\rb ")
|
||||
it "accepts an empty password and consumes only one line" $
|
||||
withPasswordInput "\r\nsecond\n" (\handle -> do
|
||||
readPasswordInput handle `shouldReturn` ""
|
||||
readPasswordInput handle `shouldReturn` "second"
|
||||
readPasswordInput handle `shouldReturn` "")
|
||||
|
||||
-- | 输出格式的用例
|
||||
formatSpec :: Spec
|
||||
|
||||
Reference in new issue
Block a user