修复错误,优化性能,实现类型系统,优化安装卸载过程

This commit is contained in:
LIm1tZz7 committed 2026-10-05 00:15:01 +08:00
1 parent fd5f320360
commit 48eec484b7
104 files changed
+4086 -804

No files matched your search

+14
View File
@@ -0,0 +1,14 @@
# 构建状态:调用共用 Linux 构建流程。
name: build
on:
push:
pull_request:
merge_group:
workflow_dispatch:
permissions:
contents: read
jobs:
build:
uses: ./.github/workflows/linux.yml
with:
phase: build
+11
View File
@@ -0,0 +1,11 @@
# 门禁状态:仅由所有者手动触发全量入口。
name: gate
on:
workflow_dispatch:
permissions:
contents: read
jobs:
gate:
uses: ./.github/workflows/linux.yml
with:
phase: gate
+34 -10
View File
@@ -1,19 +1,22 @@
# Linux 流水线:Rust 存储 + Haskell 引擎/Web/CLI 的构建与测试。
#
# 这里每条命令都在 WSL Debian(cargo 1.98 / GHC 9.10.3 / stack 3.11.1)上跑通过,
# 见仓库根 README 的「构建与测试」段。Haskell 这边链接并装入 libchusql_core_storage.so,
# 所以 Rust 先构建,LD_LIBRARY_PATH 指向它的输出目录,Haskell 用例才能真跑而不是 pending。
# 打包和发版资源走 .github/workflows/release.yml(tag 触发,也可手动 dispatch)。
name: linux
# Linux CI:构建 Rust 与 Haskell,运行各组件测试。
name: tests
on:
push:
branches: [main]
pull_request:
merge_group:
workflow_dispatch:
workflow_call:
inputs:
phase:
type: string
default: tests
permissions:
contents: read
concurrency:
group: linux-${{ github.ref }}
group: linux-${{ inputs.phase || 'tests' }}-${{ github.ref }}
cancel-in-progress: true
env:
@@ -22,7 +25,7 @@ env:
jobs:
linux:
name: build + test
name: ${{ inputs.phase || 'tests' }}
runs-on: ubuntu-latest
timeout-minutes: 90
steps:
@@ -49,10 +52,12 @@ jobs:
run: cargo build --release
- name: cargo test
if: inputs.phase != 'build' && inputs.phase != 'gate'
working-directory: chusql-core/storage
run: cargo test --release
- name: cargo clippy (-D warnings)
if: inputs.phase != 'build' && inputs.phase != 'gate'
working-directory: chusql-core/storage
run: cargo clippy --all-targets -- -D warnings
@@ -78,6 +83,7 @@ jobs:
chusql-server/.stack-work
chusql-web/.stack-work
chusql-cli/.stack-work
benchmark/.stack-work
key: ${{ runner.os }}-stack-${{ hashFiles('**/stack.yaml', '**/stack.yaml.lock') }}
restore-keys: ${{ runner.os }}-stack-
@@ -89,19 +95,37 @@ jobs:
working-directory: chusql-bootstrap
run: stack build --fast
- name: benchmark4csql build
working-directory: benchmark
run: stack build benchmark4csql --fast
- name: owner-triggered gate
if: inputs.phase == 'gate'
shell: pwsh
run: ./gate-check/run-gate.ps1
- name: engine tests
if: inputs.phase != 'build' && inputs.phase != 'gate'
working-directory: chusql-core/engine
run: stack test --fast
- name: server tests
if: inputs.phase != 'build' && inputs.phase != 'gate'
working-directory: chusql-server
run: stack test --fast
- name: web tests
if: inputs.phase != 'build' && inputs.phase != 'gate'
working-directory: chusql-web
run: stack test --fast
- name: cli tests
if: inputs.phase != 'build' && inputs.phase != 'gate'
working-directory: chusql-cli
run: stack test chusql-cli --fast
- name: bootstrap tests
if: inputs.phase != 'build' && inputs.phase != 'gate'
working-directory: chusql-bootstrap
run: stack test chusql-bootstrap --fast
+4 -4
View File
@@ -111,7 +111,7 @@ jobs:
name="chusql-${{ matrix.platform }}"
stage="dist/$name"
rm -rf "$stage"
mkdir -p "$stage/bin" "$stage/scripts"
mkdir -p "$stage/bin" "$stage/resources"
cp chusql-core/storage/target/release/libchusql_core_storage.* "$stage/bin/"
@@ -143,7 +143,7 @@ jobs:
cp "$lib" "$stage/bin/"
done
cp scripts/chusql.toml "$stage/scripts/"
cp resources/settings.toml.* "$stage/resources/"
cp scripts/install.ps1 scripts/install.sh "$stage/"
cp scripts/uninstall.ps1 scripts/uninstall.sh "$stage/"
mkdir -p "$stage/static"
@@ -166,7 +166,7 @@ jobs:
$name = "chusql-${{ matrix.platform }}"
$stage = "dist/$name"
if (Test-Path $stage) { Remove-Item -Recurse -Force $stage }
New-Item -ItemType Directory -Force -Path "$stage/bin", "$stage/scripts" | Out-Null
New-Item -ItemType Directory -Force -Path "$stage/bin", "$stage/resources" | Out-Null
Copy-Item 'chusql-core/storage/target/release/chusql_core_storage.dll' "$stage/bin/"
@@ -195,7 +195,7 @@ jobs:
Where-Object { $_.Name -notlike 'chusql*' } |
ForEach-Object { Copy-Item $_.FullName "$stage/bin/" }
Copy-Item 'scripts/chusql.toml' "$stage/scripts/"
Copy-Item 'resources/settings.toml.*' "$stage/resources/"
Copy-Item 'scripts/install.ps1', 'scripts/install.sh' $stage
Copy-Item 'scripts/uninstall.ps1', 'scripts/uninstall.sh' $stage
New-Item -ItemType Directory -Force -Path "$stage/static" | Out-Null
+6 -9
View File
@@ -38,17 +38,14 @@ target/
# 运行时数据(Rust 服务默认数据目录 CHUSQL_DATA_DIR=data/),不入库
chusql-core/storage/data/
# 一键启动脚本的默认数据目录与日志,不入库
localdata/
# 门禁运行时目录:脚本与 README 入库,临时目录与日志不入库
gate-check/tmp/
# 本地运行产物:一键启动的数据目录、门禁各车道日志与临时目录、打包产物,不入库
logs/
# 本机打出来的发行包(scripts/package.sh / package.ps1 的产物),不入库
releases/
# 前端 IDE 设置(字体等,由 /api/ui-settings 读写),本机专用,不入库
scripts/chusql.ui.settings.json
# 前端 IDE 设置(字体等,由 /api/ui-settings 读写),本机专用,不入库;resources/ 不参与打包
resources/chusql.ui.settings.json
# Cargo.lock 要入库(本项目是要产出可执行程序的 crate,不是纯库)
@@ -79,7 +76,8 @@ scripts/chusql.ui.settings.json
*~
\#*\#
.\#*
agent_credientials
AGENTS.md
agent-credientials
.dsh
# 注意:.zed/tasks.json 是本仓库共享的构建任务(stack build / run / test / repl),
# 需要入库,所以这里不忽略 .zed/
@@ -97,4 +95,3 @@ plandocs/
Thumbs.db
ehthumbs.db
desktop.ini
AGENTS.md
-34
View File
@@ -1,34 +0,0 @@
# AGENTS.md — ChuSQL 开发契约(简版)
参考:
- 进度与待办:`project-plan.txt`
- 已知问题:`known-issues.txt`
- 设计依据:`design-decisions.txt`
## 核心约束
- project-plan.txt 是唯一进度来源。
- 测试必须通过,禁止绕过检查。
- 不吞错误,使用明确错误返回。
- 表存在性以 catalog 为准。
- 语义检查和优化只使用 schema。
- 索引由存储层管理,优化器只判断可点查条件。
- 优化不能改变结果语义。
## 注释规范
- 文件级:外部引用语句(Haskell `import`、Rust `use`、JS `import`、HTML 的 `<link>`/`<script>`、
CSS `@import`、脚本的依赖/参数区)之后写一段注释,概括本文件的功能;没有引用语句的文件写在
文件开头。长度不超过 3 行、合计不超过 90 字。
- 函数级:每个函数(含 Haskell 的 `where`/`let` 内函数、Rust 的私有 `fn`)前用一行不超过
30 字的注释说明它做什么。
- 只写"做什么",不写设计理由、历史与被删掉的旧实现;那些放 `design-decisions.txt` /
`known-issues.txt`。
## 协作规则
- 全量门禁入口:`gate-check/run-gate.ps1`(三条车道并行,日志写 `gate-check/logs/`)。
- 门禁由项目所有者手动运行;agent 只跑改动模块的测试,拿到门禁结果后再提交。
- engine / web / storage 使用规定测试入口。
- 改动说明只在交流中记录。
## 当前重点
- 主线已完成:高级优化器 → 并行查询 → M1 会话隔离 → M2 快照事务 → M3 WAL 与崩溃恢复 → M4 表结构进 WAL → M5 系统表统一命名 → M6 检查点按 LSN 部分截断 → M7 事务写写冲突检测 → M8 事务保存点 → M9 角色继承 → M10 转授权 → M11 FROM 派生表与投影别名 → M12 `WITH` / CTE → M13 比较运算符补全 → M14 ANY / ALL 量词比较 → M15 优化器直方图 → M16 子查询去相关 → M17 页内完整 compact → 权限变更断开既有会话 → M18 发布链路真机验证。
- 进度与计数见 `agent_credientials/project-plan.txt`,限制与风险见 `agent_credientials/known-issues.txt`。
+10 -1
View File
@@ -1,6 +1,10 @@
# ChuSQL
本项目是一个简单的数据库项目,服务端采用 Haskell 和 Rust 联合编写
[![Release](docs/badges/release.svg)](https://github.com/TheBadRoger/ChuSQL/releases) [![Commit](docs/badges/commit.svg)](https://github.com/TheBadRoger/ChuSQL/commits/main) ![Haskell + Rust](docs/badges/languages.svg) [![Code lines](docs/badges/lines.svg)](scripts/update-code-lines.ps1)
[![Build](docs/badges/build.svg)](https://github.com/TheBadRoger/ChuSQL/actions/workflows/build.yml) [![Tests](docs/badges/tests.svg)](https://github.com/TheBadRoger/ChuSQL/actions/workflows/linux.yml) [![Gate](docs/badges/gate.svg)](https://github.com/TheBadRoger/ChuSQL/actions/workflows/gate.yml)
ChuSQL 是采用 Haskell SQL 引擎与 Rust 存储层的数据库项目,提供 TCP 服务、CLI 和 Web 管理端。支持查询优化、快照事务、WAL 崩溃恢复、DOMAIN 自定义类型及用户与角色管理。
## 快速开始
@@ -38,4 +42,9 @@ csql-web
- [安装与卸载](docs/install.md)
- [配置选项](docs/config.md)
- [命令与选项](docs/commands.md)
- [SQL 语法](docs/syntax.md)
- [类型与类型目录](docs/types.md)
- [用户、角色与属性](docs/users-and-roles.md)
- [系统引导与恢复](docs/bootstrap.md)
- [架构与组件](docs/architecture.md)
- [性能基准与瓶颈分析](docs/performance.md)
@@ -1,14 +1,15 @@
cabal-version: 2.4
name: chusql-benchmark
name: benchmark4csql
version: 1.0.0
synopsis: ChuSQL 全链路基准:起真 server,全部请求走 TCP
description: 以基准客户端身份起一个 chusql-server 进程(存储与引擎都在那个进程里),
再经 TCP JSON-lines 协议打登录、ping、catalog、查询等场景。
本程序不链引擎、不链 server 库,只用 TCP 客户端与协议模型。
synopsis: End-to-end ChuSQL benchmarks over TCP
description: Starts a chusql-server process and benchmarks authentication,
ping, catalog access, and queries through the newline-delimited
JSON TCP protocol. Uses the TCP client and shared protocol models;
query execution and storage remain in the server process.
build-type: Simple
executable chusql-benchmark
executable benchmark4csql
main-is: Main.hs
hs-source-dirs: src
build-tool-depends: chusql-server:chusql-server
@@ -12,10 +12,12 @@ import ChuSQL.Interface.Session (
runStatement,
switchDatabase,
)
import Control.Concurrent (forkIO, threadDelay)
import Control.Exception (IOException, bracket, evaluate, try)
import Control.Monad (unless, when)
import Data.List (intercalate)
import Control.Concurrent (forkIO, forkFinally, threadDelay)
import Control.Concurrent.MVar (newEmptyMVar, putMVar, takeMVar)
import Control.Exception (IOException, bracket, evaluate, throwIO, try)
import Control.Monad (forM, forM_, replicateM, unless, when)
import Data.List (intercalate, sort)
import ChuSQL.Core.Model (Value (..))
import Data.Text (Text)
import qualified Data.Text as T
import GHC.Clock (getMonotonicTime)
@@ -24,6 +26,7 @@ import System.Directory (
findExecutable,
getTemporaryDirectory,
removeDirectoryRecursive,
removeFile,
)
import System.Environment (getArgs)
import System.Exit (ExitCode (..), exitFailure)
@@ -41,6 +44,7 @@ import System.Process (
waitForProcess,
)
import Text.Printf (printf)
import Text.Read (readMaybe)
-- 全链路基准:客户端经 TCP 连 chusql-server,落盘到进程内 Rust 存储。
-- 每条语句都走完整链路:解析、语义检查、提交。
@@ -52,11 +56,17 @@ data Bench = Bench
, bDataDir :: FilePath
, bClient :: Client
, bSession :: Session
, bPort :: Int
, bConfigPath :: FilePath
}
-- | 跑一条 SQL 文本(整条链路)
runSql :: Bench -> String -> IO (Either Text QueryResult)
runSql bench sql = runStatement (bSession bench) (T.pack sql)
runSql bench sql = do
result <- runStatement (bSession bench) (T.pack sql)
case result of
Left err -> ioError (userError (T.unpack err))
Right _ -> pure result
-- | 量一次墙上耗时并强制求出行数
timedQuery :: Bench -> String -> IO (Double, Either Text Int)
@@ -90,20 +100,6 @@ benchSqlOnce bench label sql = do
report label r dt
pure r
-- | 造一条 INSERT 的 SQL 文本
userSql :: Int -> String
userSql i =
printf "INSERT INTO users (id, name, age) VALUES (%d, 'user%d', %d)" i i (i `mod` 100)
-- | 造一条订单的 INSERT
orderSql :: Int -> Int -> String
orderSql u j =
printf
"INSERT INTO orders (id, user_id, product) VALUES (%d, %d, 'item%d')"
j
(1 + (j - 1) `mod` u)
(j `mod` 50)
-- | 批量插入并报平均耗时
batchInsert :: Bench -> String -> Int -> (Int -> String) -> IO (Either Text ())
batchInsert bench label n mkSql = do
@@ -140,6 +136,7 @@ benchPing bench times = do
(total * 1000 / fromIntegral times)
hits
times
unless (hits == times) (ioError (userError "ping failed during benchmark"))
-- | 一次点查(走语句,等于把 id 索引那一支也量进去)
benchLookup :: Bench -> Int -> Int -> IO ()
@@ -158,6 +155,7 @@ benchLookup bench key times = do
(total * 1000 / fromIntegral times)
hits
times
unless (hits == times) (ioError (userError "point lookup failed during benchmark"))
-- | 量 list_catalog(只取结构,不取行)
benchCatalog :: Bench -> Int -> IO ()
@@ -175,6 +173,7 @@ benchCatalog bench n = do
(total * 1000 / fromIntegral n)
hits
n
unless (hits == n) (ioError (userError "catalog failed during benchmark"))
-- | 依次跑一批语句,返回 (耗时秒, 错误清单)
runBatch :: Bench -> [String] -> IO (Double, [Text])
@@ -224,9 +223,11 @@ runScenarios bench u o = do
putStrLn "table creation failed; stopping here"
exitFailure
putStrLn "\n[write: every INSERT is a full durable commit]"
_ <- batchInsert bench (printf "%d INSERT INTO users" u) u userSql
_ <- batchInsert bench (printf "%d INSERT INTO orders" o) o (orderSql u)
putStrLn "\n[write: 500 rows per durable INSERT commit]"
loadBatch bench "users" "id,name,age" u (\i -> printf "(%d,'user%d',%d)" i i (i `mod` 100))
loadBatch bench "orders" "id,user_id,product" o (\i -> printf "(%d,%d,'item%d')" i (1 + (i - 1) `mod` u) (i `mod` 50))
checkCount bench "users" u
checkCount bench "orders" o
putStrLn "\n[breakdown: protocol round trip vs one catalog call, both over TCP]"
benchPing bench 200
@@ -245,6 +246,7 @@ runScenarios bench u o = do
putStrLn "\n[point lookup]"
benchLookup bench (u `div` 2) 100
pressureScenarios bench u o
putStrLn "\n[batch insert: one statement with N rows, one durable commit]"
_ <- benchSqlOnce bench "CREATE TABLE batch_t" "CREATE TABLE batch_t (id int, name str, age int)"
@@ -315,6 +317,8 @@ runScenarios bench u o = do
(either T.unpack (show . qrRowCount) loadedCheck)
u
printf "secondary index: code = %d should find id = %d\n" targetCode (o `div` 2)
checkCount bench "batch_t" u
putStrLn "BENCHMARK_OK all scenarios completed"
-- | 基准用的库名
benchDatabase :: String
@@ -407,7 +411,7 @@ startBench = do
bin <- locateServerExe
tmp <- getTemporaryDirectory
stamp <- getMonotonicTime
let name = "chusql-benchmark-" ++ show (round (stamp * 1e6) :: Int)
let name = "benchmark4csql-" ++ show (round (stamp * 1e6) :: Int)
dataDir = tmp </> name
configPath = tmp </> (name ++ ".toml")
-- TOML 的普通字符串会吃反斜杠,路径统一用正斜杠
@@ -417,14 +421,14 @@ startBench = do
printf "config : %s\n" configPath
printf "data dir : %s\n" dataDir
bootstrap <- locateBootstrapExe
(bootCode, bootOut, bootErr) <- readProcessWithExitCode bootstrap ["--config", configPath, "--passwordless"] ""
(bootCode, bootOut, bootErr) <- readProcessWithExitCode bootstrap ["--config", configPath, "--passwordless", "--user", "admin"] ""
case bootCode of
ExitSuccess -> printf "bootstrap: system catalog ready\n"
_ -> do
putStrLn ("the bootstrap program failed (exit " ++ show bootCode ++ "): " ++ bootOut ++ bootErr)
exitFailure
(_, Just out, _, process) <-
createProcess (proc bin ["--config", configPath]){std_out = CreatePipe, std_err = NoStream}
createProcess (proc bin ["--config", configPath, "--user", "admin"]){std_out = CreatePipe, std_err = NoStream}
port <- readListeningPort out
_ <- forkIO (drainHandle out)
printf "server : pid launched, tcp port %d\n" port
@@ -445,17 +449,13 @@ startBench = do
exitFailure
Right () -> do
prepareDatabase session
pure (Bench process dataDir client session)
pure (Bench process dataDir client session port configPath)
-- | 基准用的配置:管理员名字、随机端口、临时数据目录
-- | 基准用的配置:随机端口、临时数据目录
benchConfigText :: String -> String
benchConfigText dataDir =
unlines
[ "[web]"
, "user = \"admin\""
, ""
, "[server]"
, "host = \"127.0.0.1\""
[ "[server]"
, "port = 0"
, ""
, "[storage]"
@@ -471,23 +471,26 @@ stopBench bench = do
putStrLn "\n[teardown] closing the TCP session, stopping the server, removing the data dir"
closeClient (bClient bench)
stopProcess (bProcess bench)
_ <- try (removeDirectoryRecursive (bDataDir bench)) :: IO (Either IOException ())
pure ()
removeDirectoryRecursive (bDataDir bench)
removeFile (bConfigPath bench)
-- | 杀掉 server 进程
stopProcess :: ProcessHandle -> IO ()
stopProcess process = do
terminateProcess process
_ <- try (waitForProcess process) :: IO (Either IOException ExitCode)
_ <- waitForProcess process
pure ()
-- | 主入口
main :: IO ()
main = do
args <- getArgs
let pick i d = if length args > i then args !! i else d
u = read (pick 0 "200") :: Int
o = read (pick 1 "200") :: Int
unless (length args <= 2) (ioError (userError "usage: benchmark4csql [users=10000] [orders=50000]"))
let values = take 2 (args ++ drop (length args) ["10000", "50000"])
sizes <- mapM positiveSize values
(u, o) <- case sizes of
[usersSize, ordersSize] -> pure (usersSize, ordersSize)
_ -> ioError (userError "expected two benchmark sizes")
printf "full-chain benchmark: TCP client --> chusql-server --> in-process Rust storage\n"
printf "dataset : users = %d rows, orders = %d rows\n" u o
printf "timing : wall clock (monotonic); every statement runs the full chain over TCP\n"
@@ -503,3 +506,121 @@ main = do
)
stopBench
(\bench -> runScenarios bench u o)
-- | 校验压力规模是至少一百的整数
positiveSize :: String -> IO Int
positiveSize raw = case readMaybe raw of
Just size | size >= 100 -> pure size
_ -> ioError (userError "benchmark row counts must be integers >= 100")
-- | 批量加载并报告每秒写入行数
loadBatch :: Bench -> String -> String -> Int -> (Int -> String) -> IO ()
loadBatch bench table columns count row = do
(milliseconds, ()) <- measure $ forM_ (chunked 500 count) $ \ids -> do
_ <- runSql bench ("INSERT INTO " ++ table ++ " (" ++ columns ++ ") VALUES " ++ intercalate "," (map row ids))
pure ()
printf "LOAD %-16s rows=%d total_ms=%.3f rows_s=%.2f\n" table count milliseconds (fromIntegral count * 1000 / milliseconds)
-- | 校验完整数据加载数量
checkCount :: Bench -> String -> Int -> IO ()
checkCount bench table expected = do
result <- runSql bench ("SELECT COUNT(*) FROM " ++ table)
case result of
Right rows | qrRows rows == [[VInt expected]] -> pure ()
_ -> ioError (userError ("count mismatch in " ++ table ++ ": " ++ show result))
-- | 用单调时钟测量动作耗时
measure :: IO a -> IO (Double, a)
measure action = do
start <- getMonotonicTime
result <- action >>= evaluate
end <- getMonotonicTime
pure ((end - start) * 1000, result)
-- | 打印均值分位数和请求吞吐量
latencies :: String -> Int -> [Double] -> IO ()
latencies label rows times = do
let ordered = sort times
count = length times
total = sum times
-- | 按最近秩取分位数
percentile fraction = ordered !! (ceiling (fraction * fromIntegral count) - 1)
printf "CASE %-24s n=%d rows=%d mean_ms=%.3f p50_ms=%.3f p95_ms=%.3f max_ms=%.3f ops_s=%.2f\n" label count rows (total / fromIntegral count) (percentile (0.5 :: Double)) (percentile (0.95 :: Double)) (last ordered) (fromIntegral count * 1000 / total)
-- | 预热后反复测量并校验结果行数
sampleQuery :: Bench -> String -> String -> Int -> IO ()
sampleQuery bench label statement expected = do
-- | 执行一次并校验结果数量
let action = do
result <- runSql bench statement
case result of
Right rows | qrRowCount rows == expected -> pure ()
_ -> ioError (userError (label ++ ": result mismatch " ++ show result))
_ <- action
times <- replicateM 7 (fst <$> measure action)
latencies label expected times
-- | 运行索引聚合连接事务与并发压力
pressureScenarios :: Bench -> Int -> Int -> IO ()
pressureScenarios bench u o = do
putStrLn "\n[pressure: one warmup, seven measured samples per read case]"
let selected = length [() | i <- [1 .. o], (1 + (i - 1) `mod` u) `mod` 100 > 90]
point = printf "SELECT id FROM users WHERE name = 'user%d'" (u `div` 2)
sampleQuery bench "scan_users" "SELECT * FROM users" u
sampleQuery bench "scan_orders" "SELECT * FROM orders" o
sampleQuery bench "id_point" (printf "SELECT id FROM users WHERE id = %d" (u `div` 2)) 1
sampleQuery bench "name_scan" point 1
_ <- benchSqlOnce bench "CREATE INDEX users(name)" "CREATE INDEX ON users (name)"
sampleQuery bench "name_index" point 1
sampleQuery bench "id_range100" "SELECT id FROM users WHERE id >= 1 AND id <= 100" 100
sampleQuery bench "sort_top100" "SELECT name FROM users ORDER BY age DESC LIMIT 100" 100
sampleQuery bench "group100" "SELECT age, COUNT(*) FROM users GROUP BY age" 100
sampleQuery bench "filtered_join" "SELECT u.name FROM users u JOIN orders o ON u.id = o.user_id WHERE u.age > 90" selected
sampleQuery bench "derived_join" "SELECT d.name FROM (SELECT id,name FROM users WHERE age > 90) d JOIN orders o ON d.id = o.user_id" selected
sampleQuery bench "cte_group" "WITH ages AS (SELECT age FROM users) SELECT age, COUNT(*) FROM ages GROUP BY age" 100
sampleQuery bench "scalar_subquery" "SELECT name, (SELECT COUNT(*) FROM orders) FROM users LIMIT 100" 100
sampleQuery bench "correlated_exists20" "SELECT u.name FROM (SELECT id,name FROM users WHERE id <= 20) u WHERE EXISTS (SELECT id FROM orders o WHERE o.user_id = u.id)" 20
_ <- runSql bench "CREATE TABLE wide (id int, payload str)"
loadBatch bench "wide" "id,payload" (min 5000 u) (\i -> printf "(%d,'%s')" i (replicate 512 'x'))
checkCount bench "wide" (min 5000 u)
sampleQuery bench "scan_wide512B" "SELECT * FROM wide" (min 5000 u)
mapM_ (concurrentLookup bench) [1, 4, 8]
_ <- runSql bench "CREATE TABLE writes (id int, name str, age int)"
_ <- batchInsert bench "200 individual durable INSERTs" 200 (\i -> printf "INSERT INTO writes (id,name,age) VALUES (%d,'w%d',%d)" i i i)
(milliseconds, ()) <- measure $ do
_ <- runSql bench "BEGIN"
forM_ [201 .. 400 :: Int] $ \i -> runSql bench (printf "INSERT INTO writes (id,name,age) VALUES (%d,'w%d',%d)" i i i) >> pure ()
_ <- runSql bench "COMMIT"
pure ()
printf "WRITE transaction200 total_ms=%.3f rows_s=%.2f\n" milliseconds (200000 / milliseconds)
checkCount bench "writes" 400
_ <- benchSqlOnce bench "UPDATE writes (400 rows)" "UPDATE writes SET age = age + 1"
_ <- runSql bench "BEGIN"
_ <- benchSqlOnce bench "transaction UPDATE (400 rows)" "UPDATE writes SET age = 0"
_ <- benchSqlOnce bench "ROLLBACK 400 changed rows" "ROLLBACK"
restored <- runSql bench "SELECT age FROM writes WHERE id = 1"
unless (fmap qrRows restored == Right [[VInt 2]]) (ioError (userError "rollback value mismatch"))
_ <- benchSqlOnce bench "DELETE writes (200 rows)" "DELETE FROM writes WHERE id > 200"
checkCount bench "writes" 200
putStrLn "PRESSURE_OK all result checks passed"
-- | 用多条独立会话同步开始点查
concurrentLookup :: Bench -> Int -> IO ()
concurrentLookup bench workers = do
completions <- forM [1 .. workers] $ \_ -> do
start <- newEmptyMVar
done <- newEmptyMVar
client <- connectClient "127.0.0.1" (bPort bench) >>= either (ioError . userError . T.unpack) pure
session <- newSession client
authenticateSession session "admin" "" >>= either (ioError . userError . T.unpack) pure
switchDatabase session "bench" >>= either (ioError . userError . T.unpack) pure
_ <- forkFinally (takeMVar start >> bracket (pure client) closeClient (\_ -> replicateM 100 (fst <$> measure (do
result <- runStatement session "SELECT id FROM users WHERE id = 1"
unless (fmap qrRows result == Right [[VInt 1]]) (ioError (userError "concurrent lookup mismatch")))))) (putMVar done)
pure (start, done)
(milliseconds, results) <- measure $ do
mapM_ (\(start, _) -> putMVar start ()) completions
mapM (takeMVar . snd) completions
times <- concat <$> mapM (either throwIO pure) results
latencies ("concurrent" ++ show workers ++ "_point") 1 times
printf "CONCURRENT workers=%d ops=%d wall_ms=%.3f aggregate_ops_s=%.2f\n" workers (workers * 100) milliseconds (fromIntegral (workers * 100) * 1000 / milliseconds)
File renamed without changes.
File renamed without changes.
+51 -42
View File
@@ -3,22 +3,22 @@
module Main (main) where
import ChuSQL.Core.Engine.Storage.IPC (closeConnection, localStorageLink, sendRawRequest, setStorageLink)
import ChuSQL.Bootstrap (preinstalledTypes, runBootstrap)
import ChuSQL.Core.Engine.Storage.FFI (maintainStorage)
import ChuSQL.Interface.Auth (hashPassword)
import ChuSQL.Interface.Config (loadWebConfigAt, rootUserName)
import ChuSQL.Interface.Settings (readSettingsFile)
import ChuSQL.Interface.TOML (resolveConfigPath)
import qualified Data.Aeson as A
import qualified Data.Aeson.KeyMap as KM
import qualified Data.ByteString as BS
import qualified Data.ByteString.Lazy.Char8 as BL
import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Text.IO as TIO
import Control.Exception (finally)
import System.Environment (getArgs)
import System.Exit (exitFailure)
import System.IO (BufferMode (LineBuffering), hSetBuffering, stdout)
-- 引导程序:建系统库与管理员账号,装完即退,留在 bin 里备用。
-- 引导程序:初始化系统库、管理员身份与内置类型目录。
data Options = Options
{ optConfig :: Maybe FilePath
@@ -26,6 +26,7 @@ data Options = Options
, optPasswordStdin :: Bool
, optPasswordless :: Bool
, optHelp :: Bool
, optMode :: Maybe String
}
-- | 全部选项都取默认值
@@ -37,6 +38,7 @@ emptyOptions =
, optPasswordStdin = False
, optPasswordless = False
, optHelp = False
, optMode = Nothing
}
-- | 解析参数
@@ -45,6 +47,10 @@ parseArgs = go emptyOptions
where
-- | 逐条消费参数列表
go opts [] = Right opts
go opts (command : rest)
| command `elem` ["repair", "recover", "recovery", "reset"] = case optMode opts of
Nothing -> go opts{optMode = Just (if command == "recovery" then "recover" else command)} rest
Just _ -> Left "only one maintenance command is allowed"
go opts ("--help" : rest) = go opts{optHelp = True} rest
go opts ("-h" : rest) = go opts{optHelp = True} rest
go opts ("--config" : v : rest) = go opts{optConfig = Just v} rest
@@ -56,19 +62,23 @@ parseArgs = go emptyOptions
-- | 打印用法说明
usage :: IO ()
usage = do
putStrLn "usage: csql-bootstrap [options]"
putStrLn "usage: csql-bootstrap [repair|recover|recovery|reset] [options]"
putStrLn " repair repair structures; abort if data recovery or initialization is needed"
putStrLn " recover replay WAL and recover system metadata; recovery is an alias"
putStrLn " reset replace system identities and grants with initial defaults"
putStrLn "Stop all database processes first. Maintenance preserves the old system directory."
putStrLn ""
putStrLn "config: chusql.toml sits at a fixed place (Windows: %APPDATA%\\ChuSQL\\chusql.toml;"
putStrLn " other: $XDG_CONFIG_HOME/ChuSQL/chusql.toml), the same file the server reads"
putStrLn "config: settings.toml sits at a fixed place (Windows: %APPDATA%\\ChuSQL\\settings.toml;"
putStrLn " other: $XDG_CONFIG_HOME/ChuSQL/settings.toml), the same file the server reads"
putStrLn ""
putStrLn " --config FILE read that config file instead"
putStrLn " --user NAME administrator name ([web] user, default root)"
putStrLn " --user NAME administrator name (default root)"
putStrLn " --password-stdin read the administrator password from one line of stdin"
putStrLn " --passwordless leave the administrator without a password"
putStrLn ""
putStrLn "what it does: creates the system database, the __system_users table and the"
putStrLn " administrator row, then exits. It never changes an existing account, and it"
putStrLn " never deletes data, so the installer and later repairs can run it again."
putStrLn "what it does: initializes the system database, an enabled login superuser and"
putStrLn " the preinstalled builtin type directory, then exits. Existing passwords and"
putStrLn " matching type definitions are preserved; conflicting definitions fail."
-- | 入口
main :: IO ()
@@ -84,13 +94,13 @@ main = do
| optHelp opts -> usage
| otherwise -> run opts
-- | 读配置、算口令哈希、发一条引导请求
-- | 读取配置和口令并完成安装引导
run :: Options -> IO ()
run opts | Just mode <- optMode opts = runMaintenance opts mode
run opts = do
configPath <- resolveConfigPath (optConfig opts)
base <- loadWebConfigAt configPath
saved <- readSettingsFile configPath
let name = maybe (rootUserName base saved) T.pack (optUser opts)
let name = maybe (rootUserName base) T.pack (optUser opts)
chosen <- readPassword opts
case chosen of
Left err -> do
@@ -112,35 +122,34 @@ run opts = do
Left err -> failWith ("cannot open the storage library: " ++ err)
Right link -> do
setStorageLink link
reply <- sendRawRequest (BL.toStrict (A.encode (bootstrapRequest name encoded)))
closeConnection
report reply
-- | 引导请求:建表、插管理员行
bootstrapRequest :: Text -> Text -> A.Value
bootstrapRequest name encoded =
A.object
[ "method" A..= ("bootstrap_system" :: Text)
, "user" A..= name
, "password_hash" A..= encoded
]
-- | 看应答:system 就是成功,其余按错误处理
report :: Either String BS.ByteString -> IO ()
report (Left err) = failWith ("cannot talk to the storage library: " ++ err)
report (Right raw) = case A.eitherDecodeStrict raw of
Right (A.Object fields)
| KM.lookup "status" fields == Just (A.String "system") ->
result <- runBootstrap send name encoded `finally` closeConnection
case result of
Left err -> failWith ("bootstrap failed: " ++ err)
Right count -> do
putStrLn "system catalog: ready"
| otherwise ->
failWith ("bootstrap failed: " ++ message fields)
Right _ -> failWith ("unexpected reply from the storage library: " ++ show raw)
Left err -> failWith ("the storage library answered something that is not JSON: " ++ err)
where
-- | 取错误文本
message fields = case KM.lookup "message" fields of
Just (A.String text) -> T.unpack text
_ -> show raw
putStrLn "administrator identity: LOGIN SUPERUSER ENABLED"
putStrLn ("preinstalled types: ready (" ++ show count ++ " definitions)")
-- | 在离线维护入口执行所选策略
runMaintenance :: Options -> String -> IO ()
runMaintenance opts mode = do
path <- resolveConfigPath (optConfig opts)
chosen <- if mode == "reset" || optPasswordStdin opts || optPasswordless opts then readPassword opts else pure (Right "")
case chosen of
Left err -> failWith err
Right plain -> do
encoded <- if T.null plain then pure "" else hashPassword plain
let request = A.object ["mode" A..= mode, "user" A..= maybe "root" id (optUser opts), "password_hash" A..= encoded, "types" A..= preinstalledTypes]
reply <- maintainStorage path (BL.toStrict (A.encode request))
case reply of
Left err -> failWith err
Right value -> BL.putStrLn (BL.fromStrict value)
-- | 通过存储链路发送引导请求
send :: A.Value -> IO (Either String A.Value)
send request = do
reply <- sendRawRequest (BL.toStrict (A.encode request))
pure (reply >>= A.eitherDecodeStrict)
-- | 读口令:stdin 一行,空行等于不设口令
readPassword :: Options -> IO (Either String Text)
+22 -3
View File
@@ -2,11 +2,20 @@ cabal-version: 2.4
name: chusql-bootstrap
version: 1.0.0
synopsis: ChuSQL 引导程序:建系统库与管理员账号
description: ChuSQL 初始化引导。首次启动前建系统库、账号表与管理员行,
装完即退,保留在 bin 里备用。后续的 catalog 修复也走这里。
synopsis: ChuSQL system catalog initialization and offline maintenance
description: Initializes the system catalog, administrator identity, and builtin
type registry during installation, preserving existing passwords
and validating type definitions. Provides offline structure repair,
WAL recovery, and system catalog reset commands.
build-type: Simple
library
hs-source-dirs: src
exposed-modules: ChuSQL.Bootstrap
build-depends: base >= 4.14 && < 5, aeson, chusql-core-model, text
default-language: Haskell2010
ghc-options: -Wall
executable csql-bootstrap
hs-source-dirs: app
main-is: Main.hs
@@ -15,6 +24,16 @@ executable csql-bootstrap
, bytestring
, chusql-core-engine
, chusql-interface
, chusql-bootstrap
, text
default-language: Haskell2010
ghc-options: -Wall
test-suite chusql-bootstrap-test
type: exitcode-stdio-1.0
hs-source-dirs: test
main-is: Spec.hs
build-depends: base >= 4.14 && < 5, aeson, bytestring, chusql-bootstrap,
chusql-core-engine, chusql-core-model, directory, filepath, hspec, text
default-language: Haskell2010
ghc-options: -Wall
+77
View File
@@ -0,0 +1,77 @@
{-# LANGUAGE OverloadedStrings #-}
module ChuSQL.Bootstrap (preinstalledTypes, runBootstrap) where
import ChuSQL.Core.Model (ColumnType (..), typeName)
import ChuSQL.Core.Protocol (Account (..))
import qualified Data.Aeson as A
import qualified Data.Aeson.KeyMap as KM
import Data.Foldable (toList)
import Data.Text (Text)
import qualified Data.Text as T
-- 引导系统身份与预装类型,校验已有定义并保留口令。
-- | 现有内置类型及其默认参数
preinstalledTypes :: [A.Value]
preinstalledTypes = map definition
[ ("int", CInt, False), ("integer", CInt, False)
, ("bigint", CBigInt, False), ("smallint", CSmallInt, False)
, ("varchar", CStr, True), ("char", CChar 1, True)
, ("text", CStr, False), ("str", CStr, False)
, ("boolean", CBool, False), ("bool", CBool, False)
, ("float", CFloat, False), ("real", CFloat, False), ("double", CDouble, False)
, ("decimal", CDecimal 10 0, True), ("numeric", CDecimal 10 0, True)
, ("date", CDate, False), ("timestamp", CTimestamp, False), ("blob", CBlob, False)
]
where
-- | 编码类型的名称与基础表示
definition :: (Text, ColumnType, Bool) -> A.Value
definition (name, base, parameterized) = A.object
["name" A..= name, "base_type" A..= typeName base, "parameterized" A..= parameterized]
-- | 依次引导身份、迁移目录并预装类型
runBootstrap :: (A.Value -> IO (Either String A.Value)) -> Text -> Text -> IO (Either String Int)
runBootstrap send name encoded = do
initialized <- send (A.object ["method" A..= ("bootstrap_system" :: Text), "user" A..= name, "password_hash" A..= encoded])
case expect "system" initialized >>= initializedCatalog of
Left err -> pure (Left err)
Right () -> do
migrated <- send (A.object ["method" A..= ("identity_initialize" :: Text), "administrator" A..= name])
case expect "accounts" migrated >>= administrator of
Left err -> pure (Left err)
Right () -> do
installed <- send (A.object ["method" A..= ("bootstrap_types" :: Text), "types" A..= preinstalledTypes])
pure (expect "rows" installed >>= installedTypes)
where
-- | 确认系统目录已初始化
initializedCatalog fields
| KM.lookup "initialized" fields == Just (A.Bool True) = Right ()
| otherwise = Left "system catalog is not initialized"
-- | 确认所选引导身份可以管理系统
administrator fields = case KM.lookup "accounts" fields of
Nothing -> Left "missing identity directory"
Just value -> case A.fromJSON value :: A.Result [Account] of
A.Error _ -> Left "invalid identity directory"
A.Success accounts
| any (\a -> accountUser a == T.toLower name && accountCanLogin a && accountEnabled a && accountIsSuperuser a) accounts -> Right ()
| otherwise -> Left "bootstrap identity is not an enabled login superuser; existing identities were preserved"
-- | 确认全部预装类型存在且定义一致
installedTypes fields = case KM.lookup "rows" fields of
Just (A.Array rows)
| all (\definition -> any (matches definition) (toList rows)) preinstalledTypes -> Right (length rows)
| otherwise -> Left "preinstalled type verification failed"
_ -> Left "missing preinstalled type directory"
-- | 比对类型定义并检查持久化编号
matches (A.Object definition) (A.Object saved) = all (\(key, value) -> KM.lookup key saved == Just value) (KM.toList definition)
&& case KM.lookup "id" saved of Just (A.Number n) -> n > 0; _ -> False
matches _ _ = False
-- | 校验响应状态并返回明确错误
expect :: Text -> Either String A.Value -> Either String (KM.KeyMap A.Value)
expect _ (Left err) = Left err
expect status (Right (A.Object fields))
| KM.lookup "status" fields == Just (A.String status) = Right fields
| Just (A.String err) <- KM.lookup "message" fields = Left (T.unpack err)
| otherwise = Left ("unexpected bootstrap response; expected " ++ T.unpack status)
expect _ (Right _) = Left "invalid bootstrap response"
+134
View File
@@ -0,0 +1,134 @@
{-# LANGUAGE OverloadedStrings #-}
module Main (main) where
import ChuSQL.Bootstrap (preinstalledTypes, runBootstrap)
import ChuSQL.Core.Protocol (Account (..))
import ChuSQL.Core.Model (columnType, typeName)
import ChuSQL.Core.Engine.Syntax.AST (Statement (CreateTable))
import ChuSQL.Core.Engine.Syntax.Parser (parseStatement)
import ChuSQL.Core.Engine.Storage.IPC (closeConnection, localStorageLink, sendRawRequest, setStorageLink)
import Control.Exception (finally)
import qualified Data.Aeson as A
import qualified Data.Aeson.KeyMap as KM
import qualified Data.ByteString.Lazy as BL
import Data.IORef (newIORef, readIORef, modifyIORef')
import Data.Text (Text)
import qualified Data.Text as T
import Data.Unique (hashUnique, newUnique)
import System.Directory (createDirectoryIfMissing, getTemporaryDirectory, removePathForcibly)
import System.FilePath ((</>))
import Test.Hspec
-- 引导测试:身份属性、类型清单、错误停止与真实持久化。
-- | 执行引导模块测试
main :: IO ()
main = hspec $ do
describe "bootstrap plan" $ do
it "registers every builtin alias with its parser defaults" $ mapM_ checkDefinition preinstalledTypes
it "initializes identities before installing types" $ do
(send, requests) <- mock [Right ready, Right (identity True), Right installed]
runBootstrap send "root" "new-hash" `shouldReturn` Right 18
calls <- requests
map (field "method") calls `shouldBe` map A.String ["bootstrap_system", "identity_initialize", "bootstrap_types"]
mapM_ (\(title, replies, err) -> it title (expectBootstrapFailure replies err))
[ ("stops on storage errors", [Left "storage unavailable"], "storage unavailable")
, ("rejects an uninitialized system response", [Right (A.object ["status" A..= ("system" :: Text), "initialized" A..= False])], "system catalog is not initialized")
, ("preserves a demoted identity and stops before type installation", [Right ready, Right (identity False)], "bootstrap identity is not an enabled login superuser; existing identities were preserved")
, ("reports type definition conflicts explicitly", [Right ready, Right (identity True), Right (A.object ["status" A..= ("error" :: Text), "message" A..= ("type conflict" :: Text)])], "type conflict")
, ("refuses incomplete type registration", [Right ready, Right (identity True), Right (A.object ["status" A..= ("rows" :: Text), "rows" A..= ([] :: [A.Value])])], "preinstalled type verification failed")
]
describe "bootstrap on real storage" $ do
it "creates root and eighteen types before any server starts" $ withStorage $ do
runBootstrap transport "root" "original-hash" `shouldReturn` Right 18
result <- transport (A.object ["method" A..= ("accounts_list" :: Text)])
case result of
Left err -> expectationFailure err
Right value -> case A.fromJSON (field "accounts" value) :: A.Result [Account] of
A.Error err -> expectationFailure err
A.Success [account] -> do
accountUser account `shouldBe` "root"
accountCanLogin account `shouldBe` True
accountEnabled account `shouldBe` True
accountIsSuperuser account `shouldBe` True
accountHash account `shouldBe` "original-hash"
A.Success _ -> expectationFailure "unexpected identities"
it "keeps the existing password and type definitions on rerun" $ withStorage $ do
runBootstrap transport "root" "original-hash" `shouldReturn` Right 18
first <- transport (A.object ["method" A..= ("accounts_list" :: Text)])
runBootstrap transport "root" "replacement-hash" `shouldReturn` Right 18
transport (A.object ["method" A..= ("accounts_list" :: Text)]) `shouldReturn` first
-- | 校验引导错误及停止请求的位置
expectBootstrapFailure :: [Either String A.Value] -> String -> Expectation
expectBootstrapFailure replies err = do
(send, requests) <- mock replies
runBootstrap send "root" "hash" `shouldReturn` Left err
fmap length requests `shouldReturn` length replies
-- | 校验预装定义与 SQL 默认类型一致
checkDefinition :: A.Value -> IO ()
checkDefinition value = case (field "name" value, field "base_type" value) of
(A.String name, A.String base) -> case parseStatement ("CREATE TABLE seeded (value " ++ T.unpack name ++ ")") of
Right (CreateTable _ [(_, column)]) -> T.pack (typeName (columnType column)) `shouldBe` base
other -> expectationFailure (show other)
_ -> expectationFailure "invalid builtin definition"
-- | 系统目录的成功响应
ready :: A.Value
ready = A.object ["status" A..= ("system" :: Text), "initialized" A..= True]
-- | 带最高权限属性的身份响应
identity :: Bool -> A.Value
identity super = A.object ["status" A..= ("accounts" :: Text), "accounts" A..= [Account 1 "root" "existing-hash" 1 "" Nothing True super True False]]
-- | 已写入编号的类型响应
installed :: A.Value
installed = A.object ["status" A..= ("rows" :: Text), "rows" A..= map numbered (zip [1 :: Int ..] preinstalledTypes)]
where
-- | 给预装定义添加持久化编号
numbered (n, A.Object fields) = A.Object (KM.insert "id" (A.toJSON n) fields)
numbered (_, value) = value
-- | 读取响应中的一个字段
field :: A.Key -> A.Value -> A.Value
field key (A.Object fields) = maybe A.Null id (KM.lookup key fields)
field _ _ = A.Null
-- | 记录请求并返回指定响应
mock :: [Either String A.Value] -> IO (A.Value -> IO (Either String A.Value), IO [A.Value])
mock replies = do
calls <- newIORef []
queuedReplies <- newIORef replies
let
-- | 消费一条模拟响应
send request = do
modifyIORef' calls (++ [request])
remaining <- readIORef queuedReplies
case remaining of
[] -> pure (Left "unexpected request")
reply : rest -> modifyIORef' queuedReplies (const rest) >> pure reply
pure (send, readIORef calls)
-- | 通过动态库存储链路发送请求
transport :: A.Value -> IO (Either String A.Value)
transport request = do
reply <- sendRawRequest (BL.toStrict (A.encode request))
pure (reply >>= A.eitherDecodeStrict)
-- | 在独立临时目录运行真实引导
withStorage :: IO () -> IO ()
withStorage action = do
tmp <- getTemporaryDirectory
unique <- hashUnique <$> newUnique
let directory = tmp </> ("chusql-bootstrap-" ++ show unique)
config = directory </> "settings.toml"
createDirectoryIfMissing True directory
writeFile config ("[storage]\ndata_dir = \"" ++ map (\c -> if c == '\\' then '/' else c) directory ++ "\"\n")
opened <- localStorageLink (Just config)
case opened of
Left err -> removePathForcibly directory >> expectationFailure err
Right link -> do
setStorageLink link
action `finally` (closeConnection >> removePathForcibly directory)
+13 -7
View File
@@ -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
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 -3
View File
@@ -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
-- | 执行一条语句并打印结果
+10 -4
View File
@@ -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
+21
View File
@@ -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)))
+14
View File
@@ -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
-5
View File
@@ -1,5 +0,0 @@
module ChuSQL.CLI.Session (module ChuSQL.Interface.Session) where
import ChuSQL.Interface.Session
-- 命令行会话:转出前端共享的会话接口,一切请求走 server。
+1
View File
@@ -8,5 +8,6 @@ packages:
- ../chusql-core/engine
- ../chusql-server
- ../chusql-web
- ../chusql-bootstrap
- ../chusql-core/model
- ../chusql-interface
+78 -1
View File
@@ -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
+4 -3
View File
@@ -2,9 +2,10 @@ cabal-version: 2.4
name: chusql-core-engine
version: 1.0.0
synopsis: ChuSQL 执行引擎:SQL 词法与语法分析、表达式求值
description: ChuSQL 服务端执行引擎。负责 SQL 词法分析与 AST 构建、
WHERE 条件表达式求值、关系代数运算与内存表管理。
synopsis: ChuSQL SQL parsing, query planning, and execution engine
description: Provides SQL tokenization, syntax and semantic analysis, query
planning and optimization, expression evaluation, and relational
algebra execution, with in-memory and persistent storage support.
build-type: Simple
library
@@ -99,6 +99,15 @@ runStatementUncheckedM db (Update tbl assigns mWhere) =
runStatementUncheckedM _ (CreateTable name cols) = do
result <- createTable name cols
pure (result >> Right [])
runStatementUncheckedM _ (CreateDomain name base) = do
result <- createTable (domainTableName name) [("base", plainColumn base)]
pure (result >> Right [])
runStatementUncheckedM _ (DropDomain name) = do
result <- dropTable (domainTableName name)
pure (result >> Right [])
runStatementUncheckedM db ShowDomains = pure (Right
[[("domain", VStr (drop (length (domainTableName "")) name)), ("base_type", VStr (typeName (columnType base)))]
| (name, table) <- db, isDomainTable name, Just base <- [lookup "base" (tableCols table)]])
runStatementUncheckedM _ (DropTable name) = do
result <- dropTable name
pure (result >> Right [])
@@ -123,6 +132,8 @@ runStatementUncheckedM db (AlterColumnNull tbl col nullable) =
alterTable db tbl (\table -> setColumnNullable col nullable (tableCols table) (tableRows table))
runStatementUncheckedM _ CreateUser{} = pure (Left "CREATE USER is executed by the account service")
runStatementUncheckedM _ AlterUser{} = pure (Left "ALTER USER is executed by the account service")
runStatementUncheckedM _ AlterIdentity{} = pure (Left "ALTER ROLE is executed by the account service")
runStatementUncheckedM _ ShowRoles = pure (Left "SHOW ROLES is executed by the account service")
runStatementUncheckedM _ DropUser{} = pure (Left "DROP USER is executed by the account service")
runStatementUncheckedM _ CreateRole{} = pure (Left "CREATE ROLE is executed by the privilege service")
runStatementUncheckedM _ DropRole{} = pure (Left "DROP ROLE is executed by the privilege service")
@@ -135,27 +135,33 @@ filterPairsM ::
[Row] ->
[Row] ->
m (Either String [Row])
filterPairsM db outer kind ncols cond lrows rrows = go lrows []
filterPairsM db outer kind ncols cond lrows rrows = go lrows [] HM.empty
where
refs = subqueryRefsIn cond
-- | 逐左行配对,左连接没配上就补 NULL
go [] acc = pure (Right (reverse acc))
go (l : ls) acc = do
hits <- keep l rrows []
go [] acc _ = pure (Right (reverse acc))
go (l : ls) acc cache = do
hits <- keep l rrows [] cache
case hits of
Left e -> pure (Left e)
Right hs -> do
Right (hs, nextCache) -> do
let hs' = case (kind, hs) of
(LeftJoin, []) -> [l ++ nullRow]
_ -> hs
go ls (reverse hs' ++ acc)
go ls (reverse hs' ++ acc) nextCache
-- | 拿一行右行去比条件
keep _ [] acc = pure (Right (reverse acc))
keep l (r : rs) acc = do
ok <- evalCondForRowM db cond (l ++ r ++ outer)
case ok of
keep _ [] acc cache = pure (Right (reverse acc, cache))
keep l (r : rs) acc cache = do
let env = l ++ r ++ outer
key = bindingKey refs env
resolved <- case HM.lookup key cache of
Just expression -> pure (Right expression)
Nothing -> substSubqueries db cond env
case resolved of
Left e -> pure (Left e)
Right True -> keep l rs ((l ++ r) : acc)
Right False -> keep l rs acc
Right expression -> case evalCondForRow expression env of
Left e -> pure (Left e)
Right matches -> keep l rs (if matches then (l ++ r) : acc else acc) (HM.insert key expression cache)
nullRow = [(c, VNull) | c <- ncols]
-- | 索引连接:右表是裸 Scan 时逐左行点查
@@ -197,6 +203,7 @@ floatType :: ColumnType -> Bool
floatType CFloat = True
floatType CDouble = True
floatType (CDecimal _ _) = True
floatType (CDomain _ base) = floatType base
floatType _ = False
-- | 是不是浮点值
@@ -249,7 +256,14 @@ evalAggregate outer keys aggs rows = do
-- | 绑定键:子查询引用的外层列在这一行上的取值
bindingKey :: [String] -> Row -> String
bindingKey refs env = show [(c, lookup c env) | c <- refs]
bindingKey refs env = show [(c, bindingValue c env) | c <- refs]
-- | 查已解析外层引用对应的物理列值
bindingValue :: String -> Row -> Maybe Value
bindingValue column env = case lookup column env of
Just value -> Just value
Nothing | '.' `elem` column -> lookup (reverse (takeWhile (/= '.') (reverse column))) [(key, value) | (key, value) <- env, '.' `notElem` key]
Nothing -> Nothing
-- | 每个绑定留第一行当样本,去掉重复绑定
bindingSamples :: [String] -> Row -> [Row] -> [(String, Row)]
@@ -414,7 +428,14 @@ substSubqueries db e env = case e of
runSubqueryRows :: (MonadStorage m) => Database -> Subquery -> Row -> m (Either String [Row])
runSubqueryRows db sq outer = case translate (subqueryStatement sq) of
Left e -> pure (Left e)
Right op -> evalRelOpIn db outer (optimize db op)
Right op -> case mapM bind (subqueryRefs sq) of
Left err -> pure (Left err)
Right bindings -> evalRelOpIn db (bindings ++ outer) (optimize db op)
where
-- | 给外层物理列补回已解析的限定名
bind column = case bindingValue column outer of
Just value -> Right (column, value)
Nothing -> Left ("unknown outer column: " ++ column)
-- | 单子求值:Scan/点查/范围查都问存储
evalRelOpM :: (MonadStorage m) => Database -> RelOp -> m (Either String [Row])
@@ -1,7 +1,7 @@
module ChuSQL.Core.Engine.Algebra.JoinOrder (reorderJoins) where
import ChuSQL.Core.Engine.Algebra.Cost (pointRows, rangeRows, selectivityOf, tableMetaOf)
import ChuSQL.Core.Engine.Algebra.Expr (colsInExpr, hasDivision)
import ChuSQL.Core.Engine.Algebra.Expr (colsInExpr, hasDivision, hasSubquery)
import ChuSQL.Core.Engine.Algebra.Op (RelOp (..), relOpCols)
import ChuSQL.Core.Engine.Syntax.AST (Expr (..), JoinKind (..))
import ChuSQL.Core.Model
@@ -46,8 +46,13 @@ leafRows db op = case op of
Scan _ t _ -> rowCountOf db t
Lookup _ t c _ -> pointRows <$> tableMetaOf db t <*> pure c
Range _ t c lo hi -> (\m -> rangeRows m c lo hi) <$> tableMetaOf db t
Filter p x -> scaleRows db p x <$> leafRows db x
Filter p x | not (hasDivision p || hasSubquery p) -> scaleRows db p x <$> leafRows db x
Project _ x -> leafRows db x
Compute items x | all (\(_, e) -> not (hasDivision e || hasSubquery e)) items -> leafRows db x
Sort _ x -> leafRows db x
Limit n x -> min (max 0 n) <$> leafRows db x
Derived _ x -> leafRows db x
Unit -> Just 1
_ -> Nothing
-- | 表统计里的行数
@@ -117,8 +117,16 @@ pushProject db need (Join kind l r c)
pushProject db need leaf@(Scan a t _)
| needsAll need = leaf
| all (`elem` need) (relOpCols db leaf) = leaf
| otherwise = Scan a t (Just (nub (map (unqualify a) need)))
pushProject _ _ (Derived a x) = Derived a x
| otherwise = Scan a t (Just (nub [column | requested <- need, let column = physicalColumn requested, column `elem` available]))
where
available = map (unqualify a) (relOpCols db (Scan a t Nothing))
-- | 把扫描限定列换成存储物理列名
physicalColumn column = unqualify (Just (deriveQualifier a t)) (unqualify a column)
pushProject db need (Derived a x)
| needsAll need || all (`elem` wanted) (relOpCols db x) = Derived a (pushProject db ["*"] x)
| otherwise = Derived a (Project wanted (pushProject db wanted x))
where
wanted = nub (map (unqualify a) need)
pushProject db need leaf@(Lookup _ _ _ _) = pushProjectLeaf db need leaf
pushProject db need leaf@(Range _ _ _ _ _) = pushProjectLeaf db need leaf
@@ -1,8 +1,9 @@
module ChuSQL.Core.Engine.Algebra.Planner (translate) where
import ChuSQL.Core.Engine.Algebra.Expr (aggregatesIn)
import ChuSQL.Core.Engine.Algebra.Expr (aggregatesIn, colsInExpr)
import ChuSQL.Core.Engine.Algebra.Op
import ChuSQL.Core.Engine.Syntax.AST
import Data.List (nub)
-- 计划生成:把语句翻译成算子树。
@@ -91,5 +92,16 @@ translate q = case q of
SelectExpr items fromC mWhere groupBy orderBy mLimit -> do
plan0 <- source fromC mWhere
let (plan, items') = grouped groupBy items plan0
Right (Compute items' (limited orderBy mLimit plan))
Right $ if any (\(c, _) -> c `elem` map fst items') orderBy
then orderedProjection items' orderBy mLimit plan
else Compute items' (limited orderBy mLimit plan)
_ -> Left "only SELECT is supported by translate"
-- | 在排序前计算别名并保留输出顺序
orderedProjection :: [(String, Expr)] -> [(String, SortDir)] -> Maybe Int -> RelOp -> RelOp
orderedProjection items orderBy mLimit plan =
Compute items (limited ordering mLimit (Compute (carried ++ keys) plan))
where
carried = [(column, Col column) | column <- nub (concatMap (colsInExpr . snd) items)]
keys = [("$order" ++ show n, maybe (Col c) id (lookup c items)) | (n, (c, _)) <- zip [1 :: Int ..] orderBy]
ordering = [(key, direction) | ((key, _), (_, direction)) <- zip keys orderBy]
@@ -36,6 +36,8 @@ errorRules =
[ ErrorRule "no database selected" NoDatabaseError "no_database"
, ErrorRule "administrator required" PermissionError "forbidden"
, ErrorRule "unknown table" CatalogError "not_found"
, ErrorRule "unknown domain" CatalogError "not_found"
, ErrorRule "domain is still used" CatalogError "query_error"
, ErrorRule "unknown column" CatalogError "query_error"
, ErrorRule "unknown database" CatalogError "not_found"
, ErrorRule "unknown role" CatalogError "not_found"
@@ -72,5 +74,7 @@ errorCode message = maybe "query_error" ruleCode (errorLike message)
-- | 账号服务对外的错误码
accountErrorCode :: String -> String
accountErrorCode "account already exists" = "conflict"
accountErrorCode "identity already exists" = "conflict"
accountErrorCode "the last enabled login superuser cannot be removed" = "bad_request"
accountErrorCode "unknown account" = "not_found"
accountErrorCode _ = "storage_error"
@@ -5,7 +5,7 @@ import ChuSQL.Core.Model
import ChuSQL.Core.Engine.Syntax.AST
import ChuSQL.Core.Engine.Syntax.Parser (parseExpression)
import Data.Char (isAlphaNum, isAscii, toLower)
import Data.List (intercalate, isInfixOf, isPrefixOf, nub)
import Data.List (intercalate, isInfixOf, isPrefixOf, isSuffixOf, nub)
-- 语义检查:表和列在不在、类型对不对,全部在执行前查。
@@ -53,10 +53,15 @@ checkFrom db outer (FromJoin _ left right cond) = do
checkBool db "ON" (Scope env outer) cond
Right env
-- | 派生表里 NULL 字面量列没有具体类型,按字符串处理
-- | 保留派生表输出的类型或未定型 NULL
derivedType :: InferredType -> ColumnType
derivedType (InferType t) = t
derivedType InferNull = CStr
derivedType InferNull = CNull
-- | 将列类型还原为表达式推导类型
inferredColumnType :: ColumnType -> InferredType
inferredColumnType CNull = InferNull
inferredColumnType ty = InferType ty
-- | 合并两层列,本层优先且不重复
scopedEnv :: [(String, Column)] -> [(String, Column)] -> [(String, Column)]
@@ -113,7 +118,7 @@ renderType (InferType t) = typeLabel t
-- | 推导表达式的类型(子查询也在这里整条查一遍)
inferExpr :: Database -> String -> Scope -> Expr -> Either String InferredType
inferExpr _ place env (Col c) = InferType . columnType . snd <$> scopeAt place env c
inferExpr _ place env (Col c) = inferredColumnType . columnType . snd <$> scopeAt place env c
inferExpr _ _ _ LitNull = Right InferNull
inferExpr _ _ _ (LitInt _) = Right (InferType CInt)
inferExpr _ _ _ (LitFloat _) = Right (InferType CFloat)
@@ -213,7 +218,7 @@ inferSubqueryOutput db outerScope sq = case subqueryStatement sq of
-- | 查这一列的推导类型
columnTypeOf env c = do
(_, col) <- scopeAt "SELECT" (Scope env enclosing) c
Right (c, InferType (columnType col))
Right (c, inferredColumnType (columnType col))
-- | 聚合的参数:列要在,且不能嵌套聚合
aggregateArg :: Database -> String -> Scope -> Expr -> Either String InferredType
@@ -282,7 +287,7 @@ boolean db place env a b = do
where
-- | 挨个查操作数是不是布尔
boolOperand _ _ InferNull = Right ()
boolOperand _ _ (InferType CBool) = Right ()
boolOperand _ _ (InferType ty) | typeClassOf ty == BooleanClass = Right ()
boolOperand p other t = Left (p ++ ": operator needs TBool on both sides, got " ++ renderType other ++ " and " ++ renderType t)
-- | 要求条件能算出布尔
@@ -292,7 +297,7 @@ checkBool db place env e = do
t <- inferExpr db place env e
case t of
InferNull -> Right ()
InferType CBool -> Right ()
InferType x | typeClassOf x == BooleanClass -> Right ()
InferType x -> Left (place ++ ": condition must be a boolean, got " ++ typeLabel x)
-- | 检查赋值列和类型
@@ -321,10 +326,88 @@ check db q = () <$ prepare db q
-- | 执行前解析列名并检查类型
prepare :: Database -> Statement -> Either String Statement
prepare db q = do
resolved <- resolveStatement db q
checkResolved db resolved
typed <- resolveDomainTypes db q
let visible = filter (not . isDomainTable . fst) db
resolved <- resolveStatement visible typed
case resolved of
CreateDomain name base -> do
checkDomainName name
checkTypeParameters base
case base of
CVarchar n | n <= 0 -> Left "CREATE DOMAIN: length must be positive"
CChar n | n <= 0 -> Left "CREATE DOMAIN: length must be positive"
CDecimal p s | p <= 0 || p > 308 || s < 0 || s > p -> Left "CREATE DOMAIN: invalid precision or scale"
_ -> Right ()
if any ((== domainTableName name) . fst) db
then Left ("domain already exists: " ++ name)
else Right ()
DropDomain name -> do
_ <- maybe (Left ("unknown domain: " ++ name)) Right (lookup (domainTableName name) db)
if any (any (usesDomain name . columnType . snd) . tableCols . snd) db
then Left ("domain is still used by a column: " ++ name)
else Right ()
ShowDomains -> Right ()
_ -> do
if any (isDomainTable . lastTablePart) (ddlTargets resolved)
then Left "reserved domain catalog"
else Right ()
checkResolved visible resolved
pure resolved
-- | 检查命名类型的名称
checkDomainName :: String -> Either String ()
checkDomainName name
| null name || length name > 48 = Left "CREATE DOMAIN: name must contain 1 to 48 characters"
| any (\c -> not (isAscii c && (isAlphaNum c || c == '_'))) name = Left "CREATE DOMAIN: invalid name"
| Just _ <- parseColumnType name = Left "CREATE DOMAIN: built-in type name is reserved"
| "__system_" `isPrefixOf` map toLower name = Left "CREATE DOMAIN: reserved name"
| otherwise = Right ()
-- | 判断列类型是否引用指定命名类型
usesDomain :: String -> ColumnType -> Bool
usesDomain name (CDomain other base) = map toLower name == map toLower other || usesDomain name base
usesDomain _ _ = False
-- | 列出结构语句的目标表
ddlTargets :: Statement -> [String]
ddlTargets statement = case statement of
CreateTable name _ -> [name]
DropTable name -> [name]
CreateIndex name _ -> [name]
DropIndex name _ -> [name]
AddColumn name _ -> [name]
DropColumn name _ -> [name]
RenameColumn name _ _ -> [name]
AlterColumnType name _ _ -> [name]
AlterColumnDefault name _ _ -> [name]
AlterColumnNull name _ _ -> [name]
_ -> []
-- | 从 schema 解析列上的命名类型
resolveDomainTypes :: Database -> Statement -> Either String Statement
resolveDomainTypes db statement = case statement of
CreateDomain name base -> CreateDomain name <$> resolveType base
CreateTable name cols -> CreateTable name <$> mapM resolveColumnType cols
AddColumn name col -> AddColumn name <$> resolveColumnType col
AlterColumnType name col ty -> AlterColumnType name col <$> resolveType ty
_ -> Right statement
where
-- | 解析一列的类型
resolveColumnType (name, col) = do
ty <- resolveType (columnType col)
Right (name, col {columnType = ty})
-- | 从类型目录取基础类型
resolveType = resolveWith []
-- | 递归解析类型并拒绝循环定义
resolveWith seen (CDomain name _)
| map toLower name `elem` seen = Left ("cyclic domain definition: " ++ name)
| otherwise = do
table <- maybe (Left ("unknown domain: " ++ name)) Right (lookup (domainTableName name) db)
base <- maybe (Left ("invalid domain catalog: " ++ name)) Right (lookup "base" (tableCols table))
resolved <- resolveWith (map toLower name : seen) (columnType base)
Right (CDomain (map toLower name) resolved)
resolveWith _ ty = Right ty
-- | 检查已解析的语句
checkResolved :: Database -> Statement -> Either String ()
checkResolved db = checkResolvedWith db []
@@ -350,8 +433,8 @@ checkResolvedWith db outer q = case q of
env <- checkFrom db outer fromC
let full = Scope env outer
mapM_ (\item -> () <$ inferExpr db "SELECT" full (snd item)) items
checkGrouping full groupBy items
checkColumns "ORDER BY" full (map fst orderBy)
checkGrouping full groupBy (items ++ [(c, Col c) | (c, _) <- orderBy, c `notElem` map fst items])
mapM_ (checkOrder full items . fst) orderBy
mapM_ (checkNoAggregate "WHERE") mWhere
mapM_ (checkBool db "WHERE" full) mWhere
Insert tbl cols rows -> do
@@ -420,6 +503,7 @@ checkResolvedWith db outer q = case q of
AlterColumnType tbl col ty -> do
t <- lookupTable db tbl
c <- requireColumn "ALTER COLUMN" t col
checkTypeParameters ty
if columnAutoIncrement c && not (integerType ty)
then Left "ALTER COLUMN: AUTO_INCREMENT column must stay an integer type"
else Right ()
@@ -435,6 +519,12 @@ checkResolvedWith db outer q = case q of
else Right ()
CreateUser name password -> checkAccount name password
AlterUser name password -> checkAccount name password
AlterIdentity name attributes -> do
checkRoleName name
if length (nub (map fst attributes)) == length attributes
then Right ()
else Left "duplicate identity attribute"
ShowRoles -> Right ()
DropUser name -> checkUserName name
CreateRole name -> checkRoleName name
DropRole name -> checkRoleName name
@@ -456,6 +546,9 @@ checkResolvedWith db outer q = case q of
DropDatabase name -> checkDatabaseName name
UseDatabase name -> checkDatabaseName name
ShowDatabases -> Right ()
CreateDomain{} -> Right ()
DropDomain{} -> Right ()
ShowDomains -> Right ()
-- | 事务控制由会话层执行,语义层不做列检查
BeginTransaction -> Right ()
CommitTransaction -> Right ()
@@ -485,6 +578,7 @@ checkInsertTargets t cols = mapM_ checkTarget (tableCols t)
-- | 检查一列定义是否合法(names 给 CHECK 用)
checkColumnDefs :: [String] -> [(String, Column)] -> Either String ()
checkColumnDefs names cols = do
mapM_ (checkTypeParameters . columnType . snd) cols
mapM_ (\(_, c) -> mapM_ (checkDefault c) (columnDefault c)) cols
mapM_ (\(_, c) -> checkAutoIncrement c) cols
if length [() | (_, c) <- cols, columnAutoIncrement c] > 1
@@ -492,6 +586,16 @@ checkColumnDefs names cols = do
else Right ()
checkChecks names cols
-- | 校验列类型的长度和精度参数
checkTypeParameters :: ColumnType -> Either String ()
checkTypeParameters (CDomain _ base) = checkTypeParameters base
checkTypeParameters CNull = Left "NULL is only valid as an inferred query output"
checkTypeParameters (CDecimal p s)
| p <= 0 || p > 308 || s < 0 || s > p = Left "invalid decimal precision or scale"
checkTypeParameters (CVarchar n) | n <= 0 = Left "character length must be positive"
checkTypeParameters (CChar n) | n <= 0 = Left "character length must be positive"
checkTypeParameters _ = Right ()
-- | 自增列必须是整数列
checkAutoIncrement :: Column -> Either String ()
checkAutoIncrement c
@@ -502,8 +606,8 @@ checkAutoIncrement c
checkDefault :: Column -> Value -> Either String ()
checkDefault c v
| VNull <- v = Right ()
| valueFits (columnType c) v = Right ()
| otherwise = Left ("DEFAULT: value does not fit column type " ++ show c)
| valueFits (columnType c) v = () <$ coerceValue (columnType c) v
| otherwise = Left ("DEFAULT: value does not fit column type " ++ typeLabel (columnType c))
-- | CHECK 里的列必须存在
checkChecks :: [String] -> [(String, Column)] -> Either String ()
@@ -717,6 +821,13 @@ resolveSelect db q@Select{} = resolveQuery db [] q (map (\c -> (c, Col c)) (sele
resolveSelect db q@SelectExpr{} = resolveQuery db [] q (selectItems q)
resolveSelect _ q = Right q
-- | 检查排序输出别名或来源列
checkOrder :: Scope -> [(String, Expr)] -> String -> Either String ()
checkOrder env items column = case [e | (label, e) <- items, label == column] of
[] -> checkColumns "ORDER BY" env [column]
[_] -> Right ()
_ -> Left ("ORDER BY: ambiguous projection alias: " ++ column)
-- | 解析投影、条件、排序与连接引用
resolveQuery :: Database -> [(String, Column)] -> Statement -> [(String, Expr)] -> Either String Statement
resolveQuery db outer q items = do
@@ -727,7 +838,13 @@ resolveQuery db outer q items = do
FromTable Nothing tbl -> unqualify (Just (deriveQualifier Nothing tbl)) c
_ -> c
-- | 查一列并换算成物理名
name place c = physical . fst <$> scopedColumnAt place env outer c
name place c
| length (filter (== '.') c) == 2, not (qualifiedReference c) = Left (missingColumn place c env)
| otherwise = physical . fst <$> scopedColumnAt place env outer c
-- | 库限定列只引用来源中的真实表名
qualifiedReference c = qualifiedReferenceIn db (selectFrom q) c
|| (any (\(key, _) -> length (filter (== '.') key) == 1 && ("." ++ key) `isSuffixOf` c) outer
&& (not (any (elem '.' . fst) db) || any (\(table, _) -> (table ++ ".") `isPrefixOf` c) db))
-- | 换算一个投影项:带别名时保留别名做输出列名
output (label, Col c) = do
k <- name "SELECT" c
@@ -742,11 +859,29 @@ resolveQuery db outer q items = do
else mapM output items
condition <- mapM (resolveExpr db env outer (name "WHERE")) (selectWhere q)
grouping <- mapM (name "GROUP BY") (selectGroupBy q)
ordering <- mapM (\(c, d) -> (\k -> (k, d)) <$> name "ORDER BY" c) (selectOrderBy q)
-- | 优先解析唯一投影别名,再查来源列
let orderName c = case [label | (label, _) <- projected, label == c, qIsExpression] of
[] -> name "ORDER BY" c
[label] -> Right label
_ -> Left ("ORDER BY: ambiguous projection alias: " ++ c)
qIsExpression = case q of SelectExpr{} -> True; _ -> False
ordering <- mapM (\(c, d) -> (\k -> (k, d)) <$> orderName c) (selectOrderBy q)
pure $ case q of
Select{} -> Select (map fst projected) source condition grouping ordering (selectLimit q)
_ -> SelectExpr projected source condition grouping ordering (selectLimit q)
-- | 收集没有别名遮蔽的来源表名
sourceTableNames :: FromClause -> [String]
sourceTableNames (FromTable Nothing table) = [table]
sourceTableNames (FromJoin _ left right _) = sourceTableNames left ++ sourceTableNames right
sourceTableNames _ = []
-- | 库限定引用必须使用当前来源的表名
qualifiedReferenceIn :: Database -> FromClause -> String -> Bool
qualifiedReferenceIn db source c = any (\table -> (table ++ ".") `isPrefixOf` c) tables
where
tables = sourceTableNames source ++ [key | table <- sourceTableNames source, Right (key, _) <- [resolveTable db table]]
-- | 单表不加限定名,其余来源交给 resolveFrom
resolveSource :: Database -> [(String, Column)] -> FromClause -> Either String FromClause
resolveSource db outer fromC = case fromC of
@@ -769,7 +904,11 @@ resolveFrom db outer (FromJoin kind left right cond) = do
l <- resolveFrom db outer left
r <- resolveFrom db outer right
env <- checkFrom db outer (FromJoin kind l r cond)
c <- resolveExpr db env outer (fmap fst . columnAt "ON" env) cond
-- | 连接条件也拒绝不存在的库前缀
let joinName column
| length (filter (== '.') column) == 2, not (qualifiedReferenceIn db (FromJoin kind left right cond) column) = Left (missingColumn "ON" column env)
| otherwise = fst <$> columnAt "ON" env column
c <- resolveExpr db env outer joinName cond
pure (FromJoin kind l r c)
-- | 派生表的输出列名:列取裸名,表达式保留原标签
@@ -7,6 +7,7 @@ module ChuSQL.Core.Engine.Storage.FFI (
openStorage,
closeStorage,
storageRequest,
maintainStorage,
) where
import Control.Exception (SomeException, try)
@@ -40,6 +41,21 @@ foreign import ccall unsafe "chusql_storage_free"
foreign import ccall unsafe "chusql_storage_last_error"
cStorageLastError :: Ptr CSize -> IO (Ptr CChar)
foreign import ccall safe "chusql_storage_maintenance"
cStorageMaintenance :: CString -> Ptr CChar -> CSize -> Ptr (Ptr CChar) -> Ptr CSize -> IO CInt
-- | 离线修复系统目录并返回结果
maintainStorage :: FilePath -> BS.ByteString -> IO (Either String BS.ByteString)
maintainStorage path payload = withCString path $ \config ->
BS.useAsCStringLen payload $ \(input, size) -> alloca $ \outRef -> alloca $ \lenRef -> do
code <- cStorageMaintenance config input (fromIntegral size) outRef lenRef
if code /= 0 then Left <$> lastError else do
output <- peek outRef
len <- peek lenRef
bytes <- BS.packCStringLen (output, fromIntegral len)
cStorageFree output len
pure (Right bytes)
-- | 库版本(Rust 侧的 CARGO_PKG_VERSION)
storageVersion :: IO String
storageVersion = cStorageVersion >>= peekCString
@@ -2,6 +2,7 @@ module ChuSQL.Core.Engine.Storage.Memory (MemoryStorage (runMemoryStorage)) wher
import ChuSQL.Core.Model
import ChuSQL.Core.Engine.Storage
import qualified Data.HashSet as HS
-- 内存实现:Database 上的状态,错误通道是 Either String。
@@ -74,11 +75,32 @@ instance MonadStorage MemoryStorage where
-- \| 原样返回当前库
snapshot = MemoryStorage $ \db -> Right (db, db)
-- \| 返回实时行数与去重统计的无数据结构
schema = MemoryStorage $ \db -> Right (map describe db, db)
where
-- | 将存储数据汇总为结构统计
describe (name, table) =
(name, table {tableRows = [], tableMeta = Just metadata})
where
metadata = TableMeta (length (tableRows table)) distinct [] []
distinct = [columnStatistics column (tableRows table) | (column, _) <- tableCols table]
-- \| 改表结构:列定义与行一起换
replaceSchema name cols rows = MemoryStorage $ \db -> case lookup name db of
Nothing -> Right (Left ("unknown table: " ++ name), db)
Just tbl -> Right (Right (), replaceTable name tbl{tableCols = cols, tableRows = rows} db)
-- | 对列值去重并限制统计内存
columnStatistics :: String -> [Row] -> (String, Int, Bool)
columnStatistics column rows = go rows HS.empty
where
-- | 累积规范化值,达到上限后停止
go remaining values
| HS.size values >= 4096 = (column, 4096, True)
| otherwise = case remaining of
[] -> (column, HS.size values, False)
row : rest -> go rest (maybe values (\v -> HS.insert (canonicalValue v) values) (lookup column row))
-- | 用给定表替换同名表
replaceTable :: String -> Table -> Database -> Database
replaceTable name table =
@@ -25,6 +25,9 @@ data Statement
| Delete String (Maybe Expr)
| Update String [(String, Expr)] (Maybe Expr)
| CreateTable String [(String, Column)]
| CreateDomain String ColumnType
| DropDomain String
| ShowDomains
| DropTable String
| CreateIndex String String
| DropIndex String String
@@ -36,6 +39,8 @@ data Statement
| AlterColumnNull String String Bool
| CreateUser String String
| AlterUser String String
| AlterIdentity String [(String, Bool)]
| ShowRoles
| DropUser String
-- | 新角色:权限的载体,用户靠成员资格继承它的权限
| CreateRole String
@@ -8,7 +8,7 @@ import ChuSQL.Core.Engine.Syntax.AST
import Control.Monad (void)
import Control.Monad.Combinators.Expr (Operator (..), makeExprParser)
import Data.Char (isAlpha, isAlphaNum, isHexDigit, isSpace, toLower, toUpper)
import Data.List (intercalate)
import Data.List (intercalate, isInfixOf)
import Data.Maybe (fromMaybe)
import Data.Void (Void)
import Text.Megaparsec
@@ -475,8 +475,25 @@ alterUserStatement = do
keyword "alter"
keyword "user"
name <- userName
pw <- identifiedBy
return (AlterUser name pw)
(AlterUser name <$> identifiedBy) <|> (AlterIdentity name <$> some identityAttribute)
-- | 读身份属性开关
identityAttribute :: Parser (String, Bool)
identityAttribute = choice
[ ("system_catalog_manager", True) <$ keyword "system_catalog_manager"
, ("system_catalog_manager", False) <$ keyword "nosystem_catalog_manager"
, ("login", True) <$ keyword "login", ("login", False) <$ keyword "nologin"
, ("superuser", True) <$ keyword "superuser", ("superuser", False) <$ keyword "nosuperuser"
, ("enabled", True) <$ keyword "enabled", ("enabled", False) <$ keyword "disabled"
]
-- | 读 ALTER ROLE 属性或口令
alterRoleStatement :: Parser Statement
alterRoleStatement = do
keyword "alter"
keyword "role"
name <- userName
(AlterUser name <$> identifiedBy) <|> (AlterIdentity name <$> some identityAttribute)
-- | 读 DROP USER
dropUserStatement :: Parser Statement
@@ -599,6 +616,7 @@ columnTypeP =
, keyword "date" >> pure CDate
, keyword "timestamp" >> pure CTimestamp
, keyword "blob" >> pure CBlob
, (\name -> CDomain (map toLower name) CStr) <$> identifier
]
-- | DECIMAL 的精度与小数位
@@ -682,7 +700,14 @@ renameColumns name cols q@Select { selectCols = names } = do
)
renameColumns name cols q@SelectExpr { selectItems = items } = do
checkCteArity name cols (map snd items)
Right q { selectItems = zip cols (map snd items) }
ordering <- mapM renameOrder (selectOrderBy q)
Right q { selectItems = zip cols (map snd items), selectOrderBy = ordering }
where
-- | 列清单同步改写内层排序输出别名
renameOrder (column, direction) = case [new | ((old, _), new) <- zip items cols, old == column] of
[] -> Right (column, direction)
[new] -> Right (new, direction)
_ -> Left ("WITH: ambiguous ORDER BY alias: " ++ column)
renameColumns _ _ _ = Left "WITH: a CTE must be a SELECT"
-- | 列清单个数必须和本体输出一致
@@ -808,7 +833,7 @@ transactionStatement =
parseStatement :: String -> Either String Statement
parseStatement input =
case runParser (sc *> statementP <* sc <* eof) "<query>" input of
Left err -> Left (errorBundlePretty err)
Left err -> Left (safeParseError input err)
Right q -> Right q
where
-- | 按关键字分派到各语句解析器
@@ -819,12 +844,17 @@ parseStatement input =
<|> try (CreateDatabase <$> (keyword "create" *> keyword "database" *> identifier))
<|> try (DropDatabase <$> (keyword "drop" *> keyword "database" *> identifier))
<|> (UseDatabase <$> (keyword "use" *> identifier))
<|> (ShowDatabases <$ (keyword "show" *> keyword "databases"))
<|> try (ShowDatabases <$ (keyword "show" *> keyword "databases"))
<|> try (ShowDomains <$ (keyword "show" *> keyword "domains"))
<|> try (CreateDomain <$> (keyword "create" *> keyword "domain" *> identifier) <*> (keyword "as" *> columnTypeP))
<|> try (DropDomain <$> (keyword "drop" *> keyword "domain" *> identifier))
<|> insertStatement
<|> deleteStatement
<|> updateStatement
<|> try createUserStatement
<|> try alterUserStatement
<|> try alterRoleStatement
<|> try (ShowRoles <$ (keyword "show" *> keyword "roles"))
<|> try dropUserStatement
<|> try createRoleStatement
<|> try dropRoleStatement
@@ -840,9 +870,15 @@ parseStatement input =
parseExpression :: String -> Either String Expr
parseExpression input =
case runParser (sc *> expr <* sc <* eof) "<expr>" input of
Left err -> Left (errorBundlePretty err)
Left err -> Left (safeParseError input err)
Right e -> Right e
-- | 隐去含口令输入的解析诊断
safeParseError :: String -> ParseErrorBundle String Void -> String
safeParseError input errors
| any (`isInfixOf` map toLower input) ["password", "identified"] = "syntax error in credential statement (input redacted)"
| otherwise = errorBundlePretty errors
-- | 表名至多有一个库前缀。
tableName :: Parser String
tableName = do
+217 -1
View File
@@ -104,9 +104,91 @@ chainDB = [("a", chainA), ("b", chainB), ("c", chainC)]
chainDBWithoutStats :: Database
chainDBWithoutStats = [(n, t { tableMeta = Nothing }) | (n, t) <- chainDB]
-- | 顺序执行命名类型测试语句
domainSql :: Database -> [String] -> Either String (Database, [Row])
domainSql db [] = Right (db, [])
domainSql db [sql] = parseStatement sql >>= runStatement db
domainSql db (sql : rest) = do
statement <- parseStatement sql
(next, _) <- runStatement db statement
domainSql next rest
-- | 跑引擎层全部 hspec 用例
main :: IO ()
main = hspec $ do
describe "identity syntax" $ do
it "parses role and user attributes with their aliases" $ do
parseStatement "ALTER ROLE worker LOGIN SUPERUSER ENABLED" `shouldBe` Right (AlterIdentity "worker" [("login", True), ("superuser", True), ("enabled", True)])
parseStatement "ALTER USER worker NOLOGIN NOSUPERUSER DISABLED" `shouldBe` Right (AlterIdentity "worker" [("login", False), ("superuser", False), ("enabled", False)])
parseStatement "ALTER ROLE worker IDENTIFIED BY 'Pass123!'" `shouldBe` Right (AlterUser "worker" "Pass123!")
parseStatement "SHOW ROLES" `shouldBe` Right ShowRoles
it "rejects empty unknown and repeated attributes" $ do
parseStatement "ALTER ROLE worker" `shouldSatisfy` isLeft
parseStatement "ALTER USER worker ACTIVE" `shouldSatisfy` isLeft
prepare [] (AlterIdentity "worker" [("login", True), ("login", False)]) `shouldSatisfy` isLeft
describe "ChuSQL.Core.Engine (DOMAIN)" $ do
it "parses domain commands and named columns" $ do
parseStatement "CREATE DOMAIN amount AS DECIMAL(12,2)" `shouldBe` Right (CreateDomain "amount" (CDecimal 12 2))
parseStatement "DROP DOMAIN amount" `shouldBe` Right (DropDomain "amount")
parseStatement "SHOW DOMAINS" `shouldBe` Right ShowDomains
parseStatement "CREATE TABLE items (price amount)" `shouldBe` Right (CreateTable "items" [("price", plainColumn (CDomain "amount" CStr))])
it "keeps a named type while applying its base semantics" $ do
let result = domainSql [] ["CREATE DOMAIN label AS VARCHAR(3)", "CREATE TABLE items (name label)", "INSERT INTO items (name) VALUES ('abc')"]
case result of
Left err -> expectationFailure err
Right (db, _) -> do
fmap columnType (lookup "items" db >>= lookup "name" . tableCols) `shouldBe` Just (CDomain "label" (CVarchar 3))
fmap snd (domainSql db ["SELECT name FROM items"]) `shouldBe` Right [[("name", VStr "abc")]]
domainSql db ["INSERT INTO items (name) VALUES ('long')"] `shouldSatisfy` isLeft
it "rejects unknown, duplicate and built-in domains" $ do
mapM_ (\sql -> domainSql [] sql `shouldSatisfy` isLeft)
[ ["CREATE TABLE items (name missing)"]
, ["CREATE DOMAIN INT AS INT"]
, ["CREATE DOMAIN a AS INT", "CREATE DOMAIN A AS INT"]
, ["CREATE DOMAIN a AS missing"]
, ["CREATE DOMAIN bad AS VARCHAR(0)"]
]
it "resolves nested domains and preserves their names and base checks" $ do
let result = domainSql [] ["CREATE DOMAIN label AS VARCHAR(3)", "CREATE DOMAIN product_label AS label", "CREATE DOMAIN title AS product_label", "CREATE TABLE items (name title)", "INSERT INTO items (name) VALUES ('abc')"]
case result of
Left err -> expectationFailure err
Right (db, _) -> do
fmap columnType (lookup "items" db >>= lookup "name" . tableCols) `shouldBe` Just (CDomain "title" (CDomain "product_label" (CDomain "label" (CVarchar 3))))
fmap snd (domainSql db ["SELECT name FROM items"]) `shouldBe` Right [[("name", VStr "abc")]]
domainSql db ["INSERT INTO items (name) VALUES ('long')"] `shouldSatisfy` isLeft
domainSql db ["DROP DOMAIN label"] `shouldSatisfy` isLeft
it "protects domain dependencies even without user columns" $ do
domainSql [] ["CREATE DOMAIN a AS INT", "CREATE DOMAIN b AS a", "DROP DOMAIN a"] `shouldSatisfy` isLeft
fmap snd (domainSql [] ["CREATE DOMAIN a AS INT", "CREATE DOMAIN b AS a", "DROP DOMAIN b", "DROP DOMAIN a", "SHOW DOMAINS"]) `shouldBe` Right []
domainSql [] ["CREATE DOMAIN self AS self"] `shouldSatisfy` isLeft
it "uses nested domains in column additions and conversions" $ do
fmap snd (domainSql [] ["CREATE DOMAIN a AS INT", "CREATE DOMAIN b AS a", "CREATE TABLE items (id INT)", "INSERT INTO items (id) VALUES (2)", "ALTER TABLE items ADD COLUMN qty b", "ALTER TABLE items ALTER COLUMN id TYPE b", "SELECT id + 1 AS next, qty FROM items"]) `shouldBe` Right [[("next", VInt 3), ("qty", VNull)]]
it "rejects cyclic catalog definitions without recursing forever" $ do
case domainSql [] ["CREATE DOMAIN a AS INT", "CREATE DOMAIN b AS a"] of
Left err -> expectationFailure err
Right (db, _) -> do
let cyclic = map (\(name, table) -> if name == domainTableName "a" then (name, table {tableCols = [("base", plainColumn (CDomain "b" CStr))]}) else (name, table)) db
fmap snd (domainSql cyclic ["CREATE TABLE items (id b)"]) `shouldBe` Left "cyclic domain definition: b"
it "prevents dropping a domain until its columns are removed" $ do
domainSql [] ["CREATE DOMAIN code AS INT", "CREATE TABLE items (code code)", "DROP DOMAIN code"] `shouldSatisfy` isLeft
fmap snd (domainSql [] ["CREATE DOMAIN code AS INT", "CREATE TABLE items (code code)", "DROP TABLE items", "DROP DOMAIN code", "SHOW DOMAINS"]) `shouldBe` Right []
domainSql [] ["DROP DOMAIN missing"] `shouldSatisfy` isLeft
it "resolves domains in ADD COLUMN and ALTER COLUMN TYPE" $ do
fmap snd (domainSql [] ["CREATE DOMAIN code AS INT", "CREATE TABLE items (id INT)", "INSERT INTO items (id) VALUES (1)", "ALTER TABLE items ADD COLUMN code code", "ALTER TABLE items ALTER COLUMN id TYPE code", "SELECT id, code FROM items"]) `shouldBe` Right [[("id", VInt 1), ("code", VNull)]]
it "hides and protects the domain catalog from SQL" $ do
domainSql [] ["CREATE DOMAIN code AS INT", "SELECT * FROM __system_domain_code"] `shouldSatisfy` isLeft
domainSql [] ["CREATE DOMAIN code AS INT", "DROP TABLE __system_domain_code"] `shouldSatisfy` isLeft
domainSql [] ["CREATE TABLE __system_domain_fake (base INT)"] `shouldSatisfy` isLeft
it "round trips named types and compares using the base type" $ do
let ty = CDomain "amount" (CDecimal 12 2)
parseColumnType (typeName ty) `shouldBe` Just ty
comparableTypes ty CInt `shouldBe` True
integerType (CDomain "code" CInt) `shouldBe` True
coerceValue (CDomain "code" CInt) (VStr "bad") `shouldSatisfy` isLeft
it "uses numeric domain columns in expressions and sorting" $ do
fmap snd (domainSql [] ["CREATE DOMAIN quantity AS INT", "CREATE TABLE items (qty quantity)", "INSERT INTO items (qty) VALUES (2), (1)", "SELECT qty + 1 AS next FROM items ORDER BY qty"]) `shouldBe` Right [[("next", VInt 2)], [("next", VInt 3)]]
it "uses boolean domains as conditions and logical operands" $ do
fmap snd (domainSql [] ["CREATE DOMAIN flag AS BOOL", "CREATE TABLE items (active flag)", "INSERT INTO items (active) VALUES (TRUE), (FALSE), (NULL)", "SELECT active FROM items WHERE active AND TRUE"]) `shouldBe` Right [[("active", VBool True)]]
describe "ChuSQL.Core.Engine.Syntax.Parser" $ do
it "resolves table-qualified columns inside database-qualified tables" $ do
let db = [("sales.users", users)]
@@ -114,6 +196,11 @@ main = hspec $ do
`shouldBe` Right [[("name", VStr "Alice")]]
rowsOf (parseStatement "SELECT sales.users.name FROM sales.users WHERE sales.users.id = 1" >>= runStatement db)
`shouldBe` Right [[("name", VStr "Alice")]]
let related = [("sales.users", users), ("sales.orders", orders)]
rowsOf (parseStatement "SELECT sales.users.name FROM sales.users WHERE EXISTS (SELECT orders.id FROM sales.orders WHERE orders.user_id = sales.users.id)" >>= runStatement related)
`shouldBe` Right [[("name", VStr "Alice")], [("name", VStr "Bob")]]
rowsOf (parseStatement "SELECT sales.users.name FROM sales.users WHERE EXISTS (SELECT orders.id FROM sales.orders WHERE orders.user_id = intruder.users.id)" >>= runStatement related)
`shouldSatisfy` either (isInfixOf "unknown column") (const False)
it "derives the column prefix when the session schema is scoped to one database" $ do
let scoped = [("users", users)]
@@ -1110,6 +1197,32 @@ main = hspec $ do
`shouldSatisfy` isLeft
describe "ChuSQL.Core.Engine (ORDER BY)" $ do
it "sorts projection aliases with source keys and LIMIT" $ do
let query = "SELECT age + 1 AS next, name FROM users ORDER BY next DESC, name LIMIT 2"
rowsOf (parseStatement query >>= runStatement testDB)
`shouldBe` Right [[("next", VInt 31), ("name", VStr "Carol")], [("next", VInt 26), ("name", VStr "Alice")]]
sameResultAsUnoptimized query
it "prefers output aliases over same-named source columns" $
rowsOf (parseStatement "SELECT -age AS age FROM users ORDER BY age LIMIT 1" >>= runStatement testDB)
`shouldBe` Right [[("age", VInt (-30))]]
it "evaluates non-ordering projections after LIMIT" $ do
rowsOf (parseStatement "SELECT name AS who, 1 / (age - 25) AS value FROM users ORDER BY who DESC LIMIT 1" >>= runStatement testDB)
`shouldBe` Right [[("who", VStr "Carol"), ("value", VInt 0)]]
rowsOf (parseStatement "SELECT 1 / 0 AS bad FROM users ORDER BY bad LIMIT 0" >>= runStatement testDB)
`shouldSatisfy` isLeft
it "sorts aggregate aliases and rejects ungrouped source sort keys" $ do
rowsOf (parseStatement "SELECT user_id, count(*) AS total FROM orders GROUP BY user_id ORDER BY total DESC" >>= runStatement testDB)
`shouldBe` Right [[("user_id", VInt 1), ("total", VInt 2)], [("user_id", VInt 2), ("total", VInt 1)]]
(parseStatement "SELECT count(*) AS total FROM users ORDER BY age" >>= runStatement testDB)
`shouldSatisfy` isLeft
it "keeps ordering aliases when a CTE renames its outputs" $
rowsOf (parseStatement "WITH t(x) AS (SELECT age + 1 AS next FROM users ORDER BY next DESC LIMIT 1) SELECT x FROM t" >>= runStatement testDB)
`shouldBe` Right [[("t.x", VInt 31)]]
it "rejects duplicate ordering aliases without changing duplicate outputs" $ do
(parseStatement "SELECT age AS x, id AS x FROM users ORDER BY x" >>= runStatement testDB)
`shouldSatisfy` either (isInfixOf "ambiguous projection alias") (const False)
rowsOf (parseStatement "SELECT age AS x, id AS x, name AS who FROM users ORDER BY who LIMIT 1" >>= runStatement testDB)
`shouldBe` Right [[("x", VInt 25), ("x", VInt 1), ("who", VStr "Alice")]]
it "parses ORDER BY DESC" $ do
case parseStatement "SELECT name FROM users ORDER BY age DESC" of
Right q -> selectOrderBy q `shouldBe` [("age", Desc)]
@@ -1402,6 +1515,38 @@ main = hspec $ do
sameResultAsUnoptimized "SELECT u.name FROM users u WHERE EXISTS (SELECT * FROM orders o WHERE o.user_id = u.id)"
describe "ChuSQL.Core.Engine (子查询去相关)" $ do
it "reuses an uncorrelated JOIN subquery across all pairs" $ do
let query = "SELECT u.name, o.product FROM users u JOIN orders o ON u.id = o.user_id AND o.product IN (SELECT product FROM orders WHERE user_id = 1)"
(result, scans) = countedRun query
fmap (map (map snd)) result `shouldBe` Right [[VStr "Alice", VStr "Book"], [VStr "Alice", VStr "Cup"]]
scanTimes "orders" scans `shouldBe` 2
sameResultAsUnoptimized query
it "reuses JOIN subqueries by values from both sides" $ do
let query = "SELECT o.product FROM orders o JOIN users u ON o.user_id = u.id AND EXISTS (SELECT id FROM users x WHERE x.id = o.user_id AND x.age = u.age)"
(result, scans) = countedRun query
fmap (map (map snd)) result `shouldBe` Right [[VStr "Book"], [VStr "Pen"], [VStr "Cup"]]
scanTimes "users" scans `shouldBe` 7
sameResultAsUnoptimized query
it "keeps unmatched LEFT JOIN rows with a cached subquery" $ do
let query = "SELECT u.name, o.product FROM users u LEFT JOIN orders o ON u.id = o.user_id AND EXISTS (SELECT id FROM users WHERE id = 1)"
(result, scans) = countedRun query
fmap (map (map snd)) result `shouldBe` Right [[VStr "Alice", VStr "Book"], [VStr "Alice", VStr "Cup"], [VStr "Bob", VStr "Pen"], [VStr "Carol", VNull]]
scanTimes "users" scans `shouldBe` 2
sameResultAsUnoptimized query
it "does not evaluate JOIN subqueries when there are no pairs" $ do
let query = "SELECT u.name FROM users u LEFT JOIN (SELECT id FROM orders WHERE id < 0) o ON EXISTS (SELECT 1 / 0 FROM orders)"
(result, scans) = countedRun query
fmap (map (map snd)) result `shouldBe` Right [[VStr "Alice"], [VStr "Bob"], [VStr "Carol"]]
scanTimes "orders" scans `shouldBe` 1
sameResultAsUnoptimized query
it "propagates JOIN subquery errors before subsequent pairs" $ do
fst (countedRun "SELECT u.name FROM users u JOIN orders o ON EXISTS (SELECT 1 / 0 FROM orders)")
`shouldSatisfy` either (isInfixOf "division by zero") (const False)
it "runs a non-correlated subquery only once" $ do
let (result, scans) = countedRun "SELECT name FROM users WHERE id IN (SELECT user_id FROM orders)"
fmap (map (map snd)) result `shouldBe` Right [[VStr "Alice"], [VStr "Bob"]]
@@ -1603,6 +1748,28 @@ main = hspec $ do
relOp = Filter cond (Limit 2 (Scan Nothing "users" Nothing))
optimize testDB relOp `shouldBe` relOp
it "projects derived outputs without dropping expression errors" $ do
let original = Project ["d.name"] (Derived (Just "d") (Compute [("name", Col "name"), ("bad", Div (LitInt 1) (LitInt 0))] (Scan Nothing "users" Nothing)))
evalRelOp testDB (optimize testDB original) `shouldBe` evalRelOp testDB original
evalRelOp testDB original `shouldBe` Left "division by zero"
sameResultAsUnoptimized "SELECT d.name FROM (SELECT name, age FROM users) d WHERE d.age > 20 ORDER BY d.name"
it "supplies memory schema statistics without exposing rows" $ do
case runMemoryStorage (schema :: MemoryStorage Database) testDB of
Left err -> expectationFailure err
Right (structures, _) -> do
map (tableRows . snd) structures `shouldBe` [[], []]
fmap (fmap metaRowCount . tableMeta) (lookup "users" structures) `shouldBe` Just (Just 3)
it "uses derived schema estimates for safe join reordering" $ do
let query = "SELECT d.name FROM (SELECT id, name FROM a) d JOIN b ON d.id = b.a_id JOIN c ON b.id = c.b_id ORDER BY d.name"
case parseStatement query >>= prepare chainDB >>= translate of
Left err -> expectationFailure err
Right original -> do
let optimized = optimize chainDB original
planTables optimized `shouldBe` ["b", "c", "a"]
evalRelOp chainDB optimized `shouldBe` evalRelOp chainDB original
it "is idempotent after reaching a fixed point" $ do
case parseStatement "SELECT u.name, o.product FROM users u JOIN orders o ON u.id = o.user_id WHERE u.age > 20" of
Left err -> expectationFailure err
@@ -2034,6 +2201,20 @@ main = hspec $ do
runIPCStorageIn (scan "iso") alpha `shouldReturn` Right [[("id", VInt 1)]]
runIPCStorageIn (scan "iso") beta `shouldReturn` Right [[("id", VInt 2)]]
it "persists nested domains and named columns across restart" $ do
withTestSession $ \srv -> do
withServerEnv srv $ do
runIPCStorage (runStatementM (CreateDomain "label" (CVarchar 3))) `shouldReturn` Right []
runIPCStorage (runStatementM (CreateDomain "product_label" (CDomain "label" CStr))) `shouldReturn` Right []
runIPCStorage (runStatementM (CreateTable "typed" [("name", plainColumn (CDomain "product_label" CStr))])) `shouldReturn` Right []
runIPCStorage (runStatementM (Insert "typed" ["name"] [[LitStr "abc"]])) `shouldReturn` Right []
closeConnection
openSessionIn (dataDir srv)
withServerEnv srv $ do
runIPCStorage (runStatementM ShowDomains) `shouldReturn` Right [[("domain", VStr "label"), ("base_type", VStr "varchar(3)")], [("domain", VStr "product_label"), ("base_type", VStr "domain(label,varchar(3))")]]
runIPCStorage (runStatementM (Insert "typed" ["name"] [[LitStr "long"]])) >>= (`shouldSatisfy` isLeft)
runIPCStorage (runStatementM (DropDomain "label")) >>= (`shouldSatisfy` isLeft)
it "rows survive a restart on the same data directory" $ do
withTestSession $ \srv -> do
withServerEnv srv $ runIPCStorage (insert "persist" [("id", VInt 42)]) `shouldReturn` Right ()
@@ -2081,6 +2262,16 @@ main = hspec $ do
-- 存储层的日志现在直接打在本进程的输出里,没有单独的 debug.log 可读;
-- 「走索引而不是全表扫」由下面 CREATE INDEX 那组用例与 Rust 侧 runtime 测试覆盖。
it "keeps correlated outer references out of physical scan columns" $ do
withTestSession $ \srv -> withServerEnv srv $ do
runIPCStorage (runStatementM (CreateTable "outer_t" [("id", TInt), ("name", TStr)])) `shouldReturn` Right []
runIPCStorage (runStatementM (CreateTable "inner_t" [("id", TInt), ("user_id", TInt), ("unused", TStr)])) `shouldReturn` Right []
runIPCStorage (runStatementM (Insert "outer_t" ["id", "name"] [[LitInt 1, LitStr "one"], [LitInt 2, LitStr "two"]])) `shouldReturn` Right []
runIPCStorage (runStatementM (Insert "inner_t" ["id", "user_id", "unused"] [[LitInt 10, LitInt 1, LitStr "x"]])) `shouldReturn` Right []
case parseStatement "SELECT u.name FROM outer_t u WHERE EXISTS (SELECT id FROM inner_t o WHERE o.user_id = u.id)" of
Left err -> expectationFailure err
Right statement -> runIPCStorage (runStatementM statement) `shouldReturn` Right [[("u.name", VStr "one")]]
it "CREATE INDEX makes a query on that column use the index" $ do
withTestSession $ \srv -> withServerEnv srv $ do
_ <- runIPCStorage (runStatementM (CreateTable "ix_t" [("id", TInt), ("code", TInt)]))
@@ -2273,6 +2464,31 @@ main = hspec $ do
vals seeded "SELECT age * 2 FROM users WHERE name = 'Nine'" `shouldBe` Right [[VNull]]
describe "ChuSQL.Core.Engine (TYPES)" $ do
it "checks rounded decimal precision, finite values and domains" $ do
coerceValue (CDecimal 4 2) (VFloat 99.994) `shouldBe` Right (VFloat 99.99)
mapM_ (\value -> coerceValue (CDecimal 4 2) value `shouldSatisfy` isLeft)
[VFloat 99.995, VFloat (-99.995), VInt 100, VFloat (0 / 0), VFloat (1 / 0)]
coerceValue (CDecimal 2 0) (VFloat 2.5) `shouldBe` Right (VFloat 2)
coerceValue (CDomain "amount" (CDecimal 4 2)) (VInt 100) `shouldSatisfy` isLeft
coerceValue (CDecimal 309 0) (VInt 1) `shouldSatisfy` isLeft
it "rejects invalid decimal definitions and overflowing defaults" $ do
mapM_ (\query -> (parseStatement query >>= runStatement testDB) `shouldSatisfy` isLeft)
["CREATE TABLE bad (v DECIMAL(2,3))", "CREATE TABLE bad (v DECIMAL(309,0))", "CREATE TABLE bad (v DECIMAL(2,0) DEFAULT 100)"]
it "does not expose password input in syntax errors" $ do
let query = "ALTER USER alice IDENTIFIED BY 'never-expose-this' trailing"
parseStatement query `shouldSatisfy` either (\err -> "syntax" `isInfixOf` err && not ("never-expose-this" `isInfixOf` err)) (const False)
it "rejects arbitrary prefixes for unqualified schema columns" $ do
resolveColumn "intruder.id" [("id", TInt)] `shouldSatisfy` isLeft
resolveColumn "wrong.u.id" [("id", TInt)] `shouldSatisfy` isLeft
mapM_ (\query -> (parseStatement query >>= runStatement testDB) `shouldSatisfy` isLeft)
["SELECT wrong.users.id FROM users", "SELECT wrong.u.id FROM users u", "SELECT users.id FROM users JOIN orders ON wrong.users.id = orders.user_id"]
it "preserves untyped NULL through derived tables and CTEs" $ do
mapM_ (\query -> rowsOf (parseStatement query >>= runStatement testDB) `shouldBe` Right [[("answer", VNull)]])
[ "SELECT d.n + 1 AS answer FROM (SELECT NULL AS n) d"
, "SELECT d.n AND TRUE AS answer FROM (SELECT NULL AS n) d"
, "WITH t AS (SELECT NULL AS n) SELECT t.n + 1 AS answer FROM t"
, "SELECT d.n + 1 AS answer FROM (SELECT x.n FROM (SELECT NULL AS n) x) d"
]
-- vals:在给定库上跑 SQL 只取值
let vals db input = fmap (map (map snd)) (parseStatement input >>= rowsOf . runStatement db)
-- withTable:先建表,返回新库
@@ -2296,7 +2512,7 @@ main = hspec $ do
, ("k", plainColumn CBool)
]
)
parseStatement "CREATE TABLE t (a nope)" `shouldSatisfy` isLeft
fmap snd (domainSql [] ["CREATE TABLE t (a nope)"]) `shouldBe` Left "unknown domain: nope"
it "reads and writes float literals" $ do
vals testDB "SELECT 1.5 + 1" `shouldBe` Right [[VFloat 2.5]]
+5 -4
View File
@@ -2,10 +2,11 @@ cabal-version: 2.4
name: chusql-core-model
version: 1.0.0
synopsis: ChuSQL 共用数据模型与线上协议类型
description: ChuSQL 各进程共用的纯数据类型:列与值的模型、存储层线上协议
(请求/响应/表结构与账号)以及查询结果集。这里不放任何执行
逻辑,前端只依赖这个包就不会把引擎链进自己的二进制。
synopsis: Shared ChuSQL data models and wire protocol types
description: Defines shared column and value models, storage protocol requests
and responses, table schemas, accounts, and query results.
Contains data types without execution logic, allowing clients to
use the models without linking the query engine.
build-type: Simple
library
+45 -8
View File
@@ -29,6 +29,8 @@ module ChuSQL.Core.Model
, typeLabel
, typeName
, parseColumnType
, domainTableName
, isDomainTable
, typeClassOf
, assignable
, comparableTypes
@@ -44,7 +46,7 @@ module ChuSQL.Core.Model
import Data.Char (isDigit, isSpace, toLower)
import Data.Hashable (Hashable (..))
import Data.List (isSuffixOf, stripPrefix)
import Data.List (isPrefixOf, isSuffixOf, stripPrefix)
import Data.Maybe (fromMaybe, mapMaybe)
-- 数据模型:列类型、列约束、值、行、表、数据库,外加查表小工具。
@@ -64,6 +66,8 @@ data ColumnType
| CDate
| CTimestamp
| CBlob
| CNull
| CDomain String ColumnType
deriving (Show, Eq)
-- | 列定义:类型 + 约束;约束全部取默认值就是普通可空列。
@@ -115,6 +119,8 @@ typeLabel (CDecimal p s) = "TDecimal " ++ show p ++ " " ++ show s
typeLabel CDate = "TDate"
typeLabel CTimestamp = "TTimestamp"
typeLabel CBlob = "TBlob"
typeLabel CNull = "NULL"
typeLabel (CDomain name _) = name
-- | 线上/磁盘上的类型名
typeName :: ColumnType -> String
@@ -131,10 +137,25 @@ typeName (CDecimal p s) = "decimal(" ++ show p ++ "," ++ show s ++ ")"
typeName CDate = "date"
typeName CTimestamp = "timestamp"
typeName CBlob = "blob"
typeName CNull = "null"
typeName (CDomain name base) = "domain(" ++ name ++ "," ++ typeName base ++ ")"
-- | 生成类型定义的目录键
domainTableName :: String -> String
domainTableName name = "__system_domain_" ++ map toLower name
-- | 判断类型定义目录键
isDomainTable :: String -> Bool
isDomainTable name = "__system_domain_" `isPrefixOf` map toLower name
-- | 解析类型名,可带长度/精度参数;不认识的给 Nothing
parseColumnType :: String -> Maybe ColumnType
parseColumnType raw = case (base, args) of
parseColumnType raw
| Just inner <- stripPrefix "domain(" raw
, Just body <- stripSuffixMaybe ")" inner
, (name, ',' : definition) <- break (== ',') body =
CDomain name <$> parseColumnType definition
| otherwise = case (base, args) of
("int", []) -> Just CInt
("integer", []) -> Just CInt
("bigint", []) -> Just CBigInt
@@ -209,6 +230,7 @@ data TypeClass
| TextClass
| TemporalClass
| BooleanClass
| NullClass
deriving (Show, Eq)
-- | 整型大类判断
@@ -226,10 +248,13 @@ typeClassOf CBlob = TextClass
typeClassOf CDate = TemporalClass
typeClassOf CTimestamp = TemporalClass
typeClassOf CBool = BooleanClass
typeClassOf CNull = NullClass
typeClassOf (CDomain _ base) = typeClassOf base
-- | 同大类可赋值;日期/时间列接受字符串字面量
assignable :: ColumnType -> ColumnType -> Bool
assignable target source
| source == CNull = True
| target == source = True
| typeClassOf target == typeClassOf source = True
| typeClassOf target == TemporalClass && typeClassOf source == TextClass = True
@@ -240,11 +265,12 @@ integerType :: ColumnType -> Bool
integerType CInt = True
integerType CBigInt = True
integerType CSmallInt = True
integerType (CDomain _ base) = integerType base
integerType _ = False
-- | 比较用的类型族:同大类可比,日期时间与文本互比
comparableTypes :: ColumnType -> ColumnType -> Bool
comparableTypes a b = sameClass || crossTemporal
comparableTypes a b = a == CNull || b == CNull || sameClass || crossTemporal
where
-- | 两个类型同属一个大类
sameClass = typeClassOf a == typeClassOf b
@@ -261,6 +287,7 @@ numericType t = typeClassOf t == NumericClass
-- | 值能不能放进这一列(先不看可空)
valueFits :: ColumnType -> Value -> Bool
valueFits (CDomain _ base) value = valueFits base value
valueFits _ VNull = True
valueFits CInt (VInt _) = True
valueFits CBigInt (VInt _) = True
@@ -297,6 +324,8 @@ coerceValue t v = case t of
CDate -> toDate v
CTimestamp -> toTimestamp v
CBlob -> toText t v
CNull -> Left "a NULL output cannot contain a non-NULL value"
CDomain _ base -> coerceValue base v
-- | 整数收进整型列
toInt :: ColumnType -> Value -> Either String Value
@@ -315,15 +344,22 @@ toFloat t _ = Left ("cannot put this value into " ++ typeLabel t)
-- | DECIMAL 按小数位四舍五入
toDecimal :: Int -> Int -> Value -> Either String Value
toDecimal p s v = case v of
toDecimal p s v
| p <= 0 || p > 308 || s < 0 || s > p = Left "invalid decimal precision or scale"
| otherwise = case v of
VInt n -> rounded (fromIntegral n)
VFloat d -> rounded d
_ -> Left ("cannot put this value into " ++ typeLabel (CDecimal p s))
where
-- | 缩放因子
scale = (10 :: Double) ^ max 0 s
scale = (10 :: Double) ^ s
-- | 按比例四舍五入
rounded d = Right (VFloat (fromIntegral (round (d * scale) :: Integer) / scale))
rounded d
| isNaN d || isInfinite d || isInfinite (d * scale) = Left "decimal value is not finite or exceeds precision"
| abs result >= (10 :: Double) ^ (p - s) = Left "decimal value exceeds precision"
| otherwise = Right (VFloat result)
where
result = fromIntegral (round (d * scale) :: Integer) / scale
-- | 字符串收进文本列
toText :: ColumnType -> Value -> Either String Value
@@ -562,5 +598,6 @@ resolveColumn name env = case [(k, v) | (k, v) <- env, matches k] of
[] -> Left ("unknown column: " ++ name)
_ -> Left ("ambiguous column: " ++ name)
where
-- | 裸名、补充了库前缀的写法,或 schema 键自带库前缀的写法
matches k = k == name || ('.' : name) `isSuffixOf` k || ('.' : k) `isSuffixOf` name
-- | 裸名匹配末段,库限定名只补一层前缀
matches k = k == name || ('.' : name) `isSuffixOf` k
|| (length (filter (== '.') name) == 2 && length (filter (== '.') k) == 1 && ('.' : k) `isSuffixOf` name)
+16 -1
View File
@@ -75,6 +75,10 @@ data Request
| ReqAllCatalog
| ReqInDatabase String Request
| ReqAccountsList
| ReqIdentityInitialize T.Text
| ReqRoleCreate T.Text
| ReqCatalogManage Request
| ReqIdentityAlter T.Text (Maybe Bool) (Maybe Bool) (Maybe Bool) (Maybe Bool)
| ReqAccountCreate T.Text T.Text
| ReqAccountReset T.Text T.Text
| ReqAccountLogin T.Text (Maybe T.Text)
@@ -134,7 +138,12 @@ instance ToJSON Request where
toJSON (ReqInDatabase name req) = case toJSON req of
A.Object fields -> A.Object (KM.insert "database" (A.String (T.pack name)) fields)
other -> other
toJSON (ReqCatalogManage command) = object ["method" .= ("catalog_manage" :: T.Text), "command" .= command]
toJSON ReqAccountsList = object ["method" .= ("accounts_list" :: T.Text)]
toJSON (ReqIdentityInitialize u) = object ["method" .= ("identity_initialize" :: T.Text), "administrator" .= u]
toJSON (ReqRoleCreate u) = object ["method" .= ("role_create" :: T.Text), "user" .= u]
toJSON (ReqIdentityAlter u login super enabled manager) = object ["method" .= ("identity_alter" :: T.Text), "user" .= u,
"can_login" .= login, "is_superuser" .= super, "enabled" .= enabled, "system_catalog_manager" .= manager]
toJSON (ReqAccountCreate u h) = object ["method" .= ("account_create" :: T.Text), "user" .= u, "password_hash" .= h]
toJSON (ReqAccountReset u h) = object ["method" .= ("account_reset" :: T.Text), "user" .= u, "password_hash" .= h]
toJSON (ReqAccountLogin u at) = object ["method" .= ("account_login" :: T.Text), "user" .= u, "at" .= at]
@@ -303,6 +312,10 @@ data Account = Account
, accountRevision :: Integer
, accountRegisteredAt :: T.Text
, accountLastLoginAt :: Maybe T.Text
, accountCanLogin :: Bool
, accountIsSuperuser :: Bool
, accountEnabled :: Bool
, accountSystemCatalogManager :: Bool
} deriving (Eq)
-- | 账号的展示:只露账号名与版本号
@@ -314,12 +327,14 @@ instance FromJSON Account where
parseJSON = withObject "Account" $ \o -> Account <$> o .: "id" <*> o .: "user"
<*> o .: "password_hash" <*> o .: "revision"
<*> o .:? "registered_at" .!= "" <*> o .:? "last_login_at"
<*> o .:? "can_login" .!= True <*> o .:? "is_superuser" .!= False <*> o .:? "enabled" .!= True <*> o .:? "system_catalog_manager" .!= False
-- | 账号编码成 JSON
instance ToJSON Account where
toJSON a = object ["id" .= accountId a, "user" .= accountUser a,
"password_hash" .= accountHash a, "revision" .= accountRevision a,
"registered_at" .= accountRegisteredAt a, "last_login_at" .= accountLastLoginAt a]
"registered_at" .= accountRegisteredAt a, "last_login_at" .= accountLastLoginAt a,
"can_login" .= accountCanLogin a, "is_superuser" .= accountIsSuperuser a, "enabled" .= accountEnabled a, "system_catalog_manager" .= accountSystemCatalogManager a]
-- | 存储层响应
data Response
@@ -1,7 +1,7 @@
# ChuSQL 存储服务配置示例
#
# 用法:安装时把本文件放到固定的用户配置目录(Windows 是 %APPDATA%\ChuSQL\chusql.toml,
# 其它平台是 ${XDG_CONFIG_HOME:-$HOME/.config}/ChuSQL/chusql.toml);
# 用法:安装时把本文件放到固定的用户配置目录(Windows 是 %APPDATA%\ChuSQL\settings.toml,
# 其它平台是 ${XDG_CONFIG_HOME:-$HOME/.config}/ChuSQL/settings.toml);
# 临时换一份就用命令行参数 --config <path>(没有环境变量通道了)。
#
# 每一项都可以删掉:没写的项回退到内置默认值(下面注释里的值就是默认值)。
@@ -28,6 +28,9 @@ pool_size = 1024
# Unix ${XDG_DATA_HOME:-$HOME/.local/share}/chusql/data,跟安装脚本装的位置一致);
# 要固定就写绝对路径,例如:
# data_dir = "/srv/chusql/data"
#
# 日志输出目录:启动器(csql-web)与安装脚本按它决定日志写哪;相对路径以启动目录为基准。
# log_files = "./logs"
[log]
# 日志等级:off / error / warn / info / debug。debug 会多打每个请求和连接。
+21 -8
View File
@@ -1,5 +1,5 @@
use std::collections::hash_map::DefaultHasher;
use std::collections::{BTreeMap, BTreeSet, HashSet};
use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::hash::{Hash, Hasher};
use std::io;
use std::io::Write;
@@ -51,9 +51,11 @@ pub struct TableSchema {
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct Catalog {
#[serde(default)]
pub identity_high_water: i64,
tables: BTreeMap<String, TableSchema>,
#[serde(skip)]
seen: BTreeMap<String, HashSet<u64>>,
seen: BTreeMap<String, HashMap<u64, u64>>,
}
impl Catalog {
@@ -153,17 +155,25 @@ impl Catalog {
for r in rows {
for (name, v) in r {
let stat = entry.stats.entry(name.clone()).or_default();
hist_remove(stat, v);
if stat.capped {
continue;
}
if seen.remove(&fold(name, value_hash(v))) {
let key = fold(name, value_hash(v));
if let Some(count) = seen.get_mut(&key) {
*count = count.saturating_sub(1);
if *count == 0 {
seen.remove(&key);
stat.distinct = stat.distinct.saturating_sub(1);
}
hist_remove(stat, v);
}
}
}
if entry.row_count == 0 {
seen.clear();
for stat in entry.stats.values_mut() {
stat.distinct = 0;
stat.capped = false;
stat.lo = None;
stat.hi = None;
stat.hist.clear();
@@ -192,17 +202,18 @@ impl Catalog {
let seen = self.seen.entry(table.to_string()).or_default();
for (name, v) in row {
let stat = entry.stats.entry(name.clone()).or_default();
hist_add(stat, v);
if stat.capped {
continue;
}
if seen.insert(fold(name, value_hash(v))) {
let count = seen.entry(fold(name, value_hash(v))).or_insert(0);
*count += 1;
if *count == 1 {
stat.distinct += 1;
if stat.distinct >= STATS_DISTINCT_CAP {
stat.capped = true;
seen.clear();
}
}
hist_add(stat, v);
}
}
@@ -476,5 +487,7 @@ fn hist_remove(stat: &mut ColumnStat, v: &serde_json::Value) {
return;
}
let index = hist_index(lo, hi, x);
stat.hist[index] = stat.hist[index].saturating_sub(1);
if let Some(bucket) = (0..stat.hist.len()).filter(|&i| stat.hist[i] > 0).min_by_key(|&i| i.abs_diff(index)) {
stat.hist[bucket] -= 1;
}
}
+19 -4
View File
@@ -8,7 +8,7 @@ use crate::log::Level;
// 存储相关分区由本模块解析校验,其它层的分区一概不碰。
/// 配置文件名
pub const CONFIG_FILE_NAME: &str = "chusql.toml";
pub const CONFIG_FILE_NAME: &str = "settings.toml";
/// 配置目录名(随系统惯例放在用户配置目录下)
pub const APP_DIR_NAME: &str = "ChuSQL";
/// Unix 下的数据目录名(XDG 惯例小写,跟安装脚本的默认安装目录一致)
@@ -20,6 +20,8 @@ pub const DEFAULT_POOL_SIZE: usize = 1024;
pub const DEFAULT_PAGE_SIZE: usize = 4096;
pub const DEFAULT_BTREE_ORDER: usize = 4;
pub const DEFAULT_LOG_LEVEL: &str = "info";
/// 默认日志输出目录(相对启动目录)
pub const DEFAULT_LOG_FILES: &str = "./logs";
const MIN_PAGE_SIZE: usize = 512;
const MAX_PAGE_SIZE: usize = 65536;
@@ -91,6 +93,7 @@ pub struct Config {
pub btree_order: usize,
pub pool_size: usize,
pub data_dir: PathBuf,
pub log_files: PathBuf,
pub log_level: Level,
}
@@ -102,6 +105,7 @@ impl Default for Config {
btree_order: DEFAULT_BTREE_ORDER,
pool_size: DEFAULT_POOL_SIZE,
data_dir: default_data_dir(),
log_files: PathBuf::from(DEFAULT_LOG_FILES),
log_level: Level::Info,
}
}
@@ -161,6 +165,7 @@ struct FileBtree {
#[serde(deny_unknown_fields)]
struct FileStorage {
data_dir: Option<String>,
log_files: Option<String>,
}
#[derive(Debug, Default, Deserialize)]
@@ -229,6 +234,11 @@ pub fn resolve(text: Option<&str>, path: Option<PathBuf>) -> Result<Loaded, Stri
default_data_dir().to_string_lossy().into_owned(),
&file_origin,
);
let (log_files, log_files_origin) = pick(
file.storage.as_ref().and_then(|s| s.log_files.clone()),
DEFAULT_LOG_FILES.to_string(),
&file_origin,
);
// 管名已经没有了:存储不再是独立进程,管道/套接字那一层随 FFI 一起删掉
let (log_level, log_origin) = pick(
file.log.and_then(|l| l.level),
@@ -237,6 +247,7 @@ pub fn resolve(text: Option<&str>, path: Option<PathBuf>) -> Result<Loaded, Stri
);
let data_dir = data_dir.trim().to_string();
let log_files = log_files.trim().to_string();
let log_level = log_level.trim().to_string();
let log_level = Level::parse(&log_level)
@@ -245,6 +256,9 @@ pub fn resolve(text: Option<&str>, path: Option<PathBuf>) -> Result<Loaded, Stri
if data_dir.is_empty() {
return Err("storage.data_dir must not be empty".to_string());
}
if log_files.is_empty() {
return Err("storage.log_files must not be empty".to_string());
}
validate_layout(page_size, btree_order)?;
if pool_size == 0 {
return Err("buffer.pool_size must be at least 1".to_string());
@@ -254,6 +268,7 @@ pub fn resolve(text: Option<&str>, path: Option<PathBuf>) -> Result<Loaded, Stri
("btree.order", btree_order.to_string(), order_origin),
("buffer.pool_size", pool_size.to_string(), pool_origin),
("storage.data_dir", data_dir.clone(), dir_origin),
("storage.log_files", log_files.clone(), log_files_origin),
("log.level", log_level.name().to_string(), log_origin),
];
@@ -263,6 +278,7 @@ pub fn resolve(text: Option<&str>, path: Option<PathBuf>) -> Result<Loaded, Stri
btree_order,
pool_size,
data_dir: PathBuf::from(data_dir),
log_files: PathBuf::from(log_files),
log_level,
},
config_path: if text.is_some() { path } else { None },
@@ -318,15 +334,14 @@ size = 8192
data_dir = "../data"
[server]
host = "127.0.0.1"
listen_host = "127.0.0.1"
port = 7777
max_message = 1048576
[web]
port = 7778
user = "root"
"#;
let loaded = resolve(Some(text), Some(PathBuf::from("chusql.toml"))).unwrap();
let loaded = resolve(Some(text), Some(PathBuf::from("settings.toml"))).unwrap();
assert_eq!(loaded.config.page_size, 8192);
assert_eq!(loaded.config.data_dir, PathBuf::from("../data"));
}
+27
View File
@@ -71,6 +71,33 @@ pub unsafe extern "C" fn chusql_storage_close(handle: *mut Storage) {
let _ = catch_unwind(AssertUnwindSafe(|| unsafe { drop(Box::from_raw(handle)) }));
}
/// 离线维护系统目录并返回 JSON
///
/// # Safety
/// 输入为有效字符串;输出指针可写,响应使用 free 释放。
#[unsafe(no_mangle)]
pub unsafe extern "C" fn chusql_storage_maintenance(
config_path: *const c_char, request: *const u8, request_len: usize,
out: *mut *mut u8, out_len: *mut usize,
) -> i32 {
if request.is_null() || out.is_null() || out_len.is_null() { set_error("invalid maintenance argument"); return ERR; }
unsafe { *out = ptr::null_mut(); *out_len = 0; }
let result = catch_unwind(AssertUnwindSafe(|| {
let path = if config_path.is_null() { None } else { Some(unsafe { CStr::from_ptr(config_path) }.to_str().map_err(|e| e.to_string())?) };
let text = std::str::from_utf8(unsafe { slice::from_raw_parts(request, request_len) }).map_err(|e| e.to_string())?;
Storage::maintenance(path, text)
}));
let response = match result {
Ok(Ok(response)) => response,
Ok(Err(err)) => { set_error(err); return ERR; }
Err(_) => { set_error("panic in system maintenance"); return ERR; }
};
let mut buffer = response.into_bytes().into_boxed_slice();
unsafe { *out = buffer.as_mut_ptr(); *out_len = buffer.len(); }
std::mem::forget(buffer);
OK
}
/// 处理一条请求,响应缓冲须归还
///
/// # Safety
+22 -2
View File
@@ -6,6 +6,8 @@ use std::collections::HashMap;
pub type Row = HashMap<String, serde_json::Value>;
pub const USERS_TABLE: &str = "__system_users";
pub const IDENTITIES_TABLE: &str = "__system_identities";
pub const TYPES_TABLE: &str = "__system_types";
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Account {
@@ -17,6 +19,16 @@ pub struct Account {
pub registered_at: String,
#[serde(default)]
pub last_login_at: Option<String>,
#[serde(default = "default_true")]
pub can_login: bool,
#[serde(default)]
pub is_superuser: bool,
#[serde(default)]
pub system_catalog_manager: bool,
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default)]
pub identity_version: u8,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
@@ -80,7 +92,11 @@ pub struct IndexWire {
#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "method", rename_all = "snake_case")]
pub enum Request {
CatalogManage { command: Box<Request> },
AccountsList,
IdentityInitialize { administrator: String },
RoleCreate { user: String },
IdentityAlter { user: String, can_login: Option<bool>, is_superuser: Option<bool>, enabled: Option<bool>, system_catalog_manager: Option<bool> },
AccountCreate { user: String, password_hash: String },
AccountReset { user: String, password_hash: String },
AccountLogin {
@@ -97,6 +113,7 @@ pub enum Request {
password_hash: Option<String>,
},
SystemStatus,
BootstrapTypes { types: Vec<Row> },
Ping,
Scan {
table: String,
@@ -222,9 +239,11 @@ impl Request {
| Self::DropIndex { table, .. } | Self::DropColumn { table, .. }
| Self::Compact { table }
| Self::ReplaceSchema { table, .. } => Some(table),
Self::Ping | Self::ListTables | Self::ListCatalog | Self::AccountsList
Self::Ping | Self::ListTables | Self::ListCatalog | Self::AccountsList | Self::CatalogManage { .. }
| Self::IdentityInitialize { .. } | Self::RoleCreate { .. } | Self::IdentityAlter { .. }
| Self::AccountCreate { .. } | Self::AccountReset { .. } | Self::AccountLogin { .. }
| Self::AccountDrop { .. } | Self::BootstrapSystem { .. } | Self::SystemStatus
| Self::BootstrapTypes { .. }
| Self::ApplyTransaction { .. } => None,
}
}
@@ -245,7 +264,8 @@ pub const PRIVILEGE_TABLES: [&str; 4] = [
/// 是不是服务自己经请求通道读写的系统表
pub fn internal_table(table: &str) -> bool {
PRIVILEGE_TABLES.contains(&table.to_ascii_lowercase().as_str())
let name = table.to_ascii_lowercase();
PRIVILEGE_TABLES.contains(&name.as_str()) || name.starts_with("__system_domain_")
}
/// 是不是请求通道要拒绝的系统表
+528 -29
View File
@@ -8,8 +8,8 @@ use crate::config::{self, Config, Loaded};
use crate::heap::{scalar_int, HeapTable};
use crate::log;
use crate::protocol::{
blocked_table, reserved_table, Account, ColumnStatWire, IndexWire, Request, Response, Row,
SchemaColumn, StorageOp, TableSchemaWire, USERS_TABLE,
blocked_table, reserved_table, Account, ColumnStatWire, IndexWire, Request, Response, Row, IDENTITIES_TABLE,
SchemaColumn, StorageOp, TableSchemaWire, USERS_TABLE, TYPES_TABLE,
};
use crate::wal::{committed_ops, records_after, Wal, WalOp};
use crate::{log_debug, log_error, log_info, log_warn};
@@ -222,15 +222,41 @@ impl Server {
c.rebuild_stats(table, rows);
c.save(self.catalog_path())
}
WalOp::Accounts { accounts } => {
WalOp::Accounts { accounts, removed, migrate_roles } => {
let rows = account_rows(accounts)?;
self.tables.lock().map_err(|_| std::io::Error::other("table lock poisoned"))?.remove(USERS_TABLE);
for path in [self.table_path(USERS_TABLE), self.index_path(USERS_TABLE, "id")] {
let identities = identity_rows(accounts)?;
for (table, records) in [(USERS_TABLE, &rows), (IDENTITIES_TABLE, &identities)] {
self.tables.lock().map_err(|_| std::io::Error::other("table lock poisoned"))?.remove(table);
for path in [self.table_path(table), self.index_path(table, "id")] {
std::fs::File::create(path)?.sync_all()?;
}
self.with_table(USERS_TABLE, |t| { t.replace_all(&rows)?; t.flush() })?;
self.with_table(table, |t| { t.replace_all(records)?; t.flush() })?;
}
let legacy_roles = "__system_roles";
let has_roles = self.catalog.lock().map_err(|_| std::io::Error::other("catalog lock poisoned"))?.describe(legacy_roles).is_some();
if has_roles && *migrate_roles {
self.with_existing_table(legacy_roles, |t| { t.replace_all(&[])?; t.flush() })?;
}
let mut cleaned = Vec::new();
for table in ["__system_grants", "__system_grant_options", "__system_members"] {
let exists = self.catalog.lock().map_err(|_| std::io::Error::other("catalog lock poisoned"))?.describe(table).is_some();
if exists && !removed.is_empty() {
let rows = self.with_existing_table(table, |t| t.scan())?.into_iter().filter(|r|
!["role", "member"].iter().any(|field| r.get(*field).and_then(serde_json::Value::as_str).is_some_and(|name| removed.iter().any(|old| old == name)))).collect::<Vec<_>>();
self.with_existing_table(table, |t| { t.replace_all(&rows)?; t.flush() })?;
cleaned.push((table, rows));
}
}
let mut c = self.catalog.lock().map_err(|_| std::io::Error::other("catalog lock poisoned"))?;
declare_system_schema(&mut c, &rows)?;
c.rebuild_stats(IDENTITIES_TABLE, &identities);
c.set_columns(IDENTITIES_TABLE, identity_columns())?;
c.rebuild_stats(IDENTITIES_TABLE, &identities);
c.mark_system(IDENTITIES_TABLE)?;
c.add_index(IDENTITIES_TABLE, "user")?;
c.identity_high_water = c.identity_high_water.max(accounts.iter().map(|a| a.id).max().unwrap_or(0));
if has_roles && *migrate_roles { c.rebuild_stats(legacy_roles, &[]); }
for (table, records) in cleaned { c.rebuild_stats(table, &records); }
c.save(self.catalog_path())
}
WalOp::Insert { table, row } => {
@@ -284,6 +310,7 @@ impl Server {
self.with_table(table, |_t| Ok(()))?;
let mut c = self.catalog.lock().map_err(|_| std::io::Error::other("catalog lock poisoned"))?;
c.create_table(table, columns.clone())?;
if table == TYPES_TABLE { c.mark_system(table)?; }
c.save(self.catalog_path())
}
WalOp::DropTable { table } => {
@@ -342,8 +369,67 @@ impl Server {
if self.recovery_required.load(Ordering::Acquire) {
return Err(std::io::Error::other("storage recovery required"));
}
self.validate_op(op)?;
self.group_via_wal(std::slice::from_ref(op))
let op = self.normalize_decimal_write(op)?;
self.validate_op(&op)?;
self.validate_domains(&op)?;
self.group_via_wal(std::slice::from_ref(&op))
}
/// 写日志前规范化定点数值
fn normalize_decimal_write(&self, op: &WalOp) -> std::io::Result<WalOp> {
let mut normalized = op.clone();
let catalog = self.catalog.lock().map_err(|_| std::io::Error::other("catalog lock poisoned"))?;
let (table, rows): (&str, Vec<&mut Row>) = match &mut normalized {
WalOp::Insert { table, row } => (table, vec![row]),
WalOp::InsertBatch { table, rows } | WalOp::ReplaceAll { table, rows } => (table, rows.iter_mut().collect()),
WalOp::CreateTable { columns, .. } => {
for column in columns { decimal_parameters(&column.ty)?; }
return Ok(normalized);
}
WalOp::ReplaceSchema { columns, rows, .. } => {
for column in columns.iter() { decimal_parameters(&column.ty)?; }
for row in rows { normalize_decimal_row(row, columns)?; }
return Ok(normalized);
}
_ => return Ok(normalized),
};
if let Some(schema) = catalog.describe(table) {
for row in rows {
normalize_decimal_row(row, &schema.columns)?;
}
}
Ok(normalized)
}
/// 校验命名类型引用与删除依赖
fn validate_domains(&self, op: &WalOp) -> std::io::Result<()> {
let catalog = self.catalog.lock().map_err(|_| std::io::Error::other("catalog lock poisoned"))?;
match op {
WalOp::CreateTable { columns, .. } | WalOp::ReplaceSchema { columns, .. } => {
for column in columns {
let mut ty = column.ty.as_str();
while let Some(body) = ty.strip_prefix("domain(").and_then(|s| s.strip_suffix(')')) {
let (name, base) = body.split_once(',').ok_or_else(|| std::io::Error::other("invalid domain type"))?;
let key = format!("__system_domain_{name}");
let definition = catalog.describe(&key).ok_or_else(|| std::io::Error::other(format!("unknown domain: {name}")))?;
if !definition.columns.iter().any(|c| c.name == "base" && c.ty == base) {
return Err(std::io::Error::other(format!("domain base type mismatch: {name}")));
}
ty = base;
}
}
}
WalOp::DropTable { table } => {
if let Some(name) = table.strip_prefix("__system_domain_") {
let prefix = format!("domain({name},");
if catalog.all_tables().iter().any(|(_, schema)| schema.columns.iter().any(|c| !schema.dropped_columns.contains(&c.name) && c.ty.contains(&prefix))) {
return Err(std::io::Error::other(format!("domain is still used by a column: {name}")));
}
}
}
_ => {}
}
Ok(())
}
/// 校验一条操作:命中被删的列就拒绝
@@ -406,6 +492,7 @@ impl Server {
rows: rows.clone(),
},
};
let wal_op = self.normalize_decimal_write(&wal_op)?;
self.validate_op(&wal_op)?;
wal_ops.push(wal_op);
}
@@ -420,13 +507,9 @@ impl Server {
std::fs::create_dir_all(&self.cfg.data_dir)?;
let wal = &self.wal;
let commit_lsn = wal.append_group(ops)?;
for op in ops {
let result = self.apply_op(op);
if result.is_err() && matches!(op, WalOp::HideColumn { .. }) {
self.recovery_required.store(true, Ordering::Release);
return result;
}
result?;
for op in ops {
self.apply_op(op)?;
}
for op in ops {
self.flush_op_tables(op)?;
@@ -434,6 +517,7 @@ impl Server {
self.flush_all_tables()?;
wal.write_checkpoint(commit_lsn)?;
wal.truncate_before(commit_lsn)?;
self.recovery_required.store(false, Ordering::Release);
log_debug!(core, "WAL committed group at LSN {}", commit_lsn);
Ok(())
}
@@ -479,15 +563,15 @@ impl Server {
fn recover_wal(&self) -> std::io::Result<()> {
std::fs::create_dir_all(&self.cfg.data_dir)?;
let wal = &self.wal;
let checkpoint = wal.read_checkpoint()?.unwrap_or(0);
let scan = wal.read_all()?;
if scan.records.is_empty() {
if scan.good_len > 0 {
if wal.path().exists() {
wal.truncate_to(scan.good_len)?;
}
return Ok(());
}
// 检查点以内的记录已经落盘,直接按边界截掉
let checkpoint = wal.read_checkpoint()?.unwrap_or(0);
let pending = records_after(&scan.records, checkpoint);
let records = committed_ops(&pending);
if records.is_empty() {
@@ -859,6 +943,12 @@ impl Server {
use std::io::{Error, ErrorKind};
let registered = {
let c = self.catalog.lock().map_err(|_| Error::other("catalog lock poisoned"))?;
match c.describe(IDENTITIES_TABLE) {
Some(schema) if !schema.system => return Err(Error::new(ErrorKind::AlreadyExists, "reserved identity table collision")),
None if self.table_path(IDENTITIES_TABLE).exists() || self.index_path(IDENTITIES_TABLE, "id").exists() =>
return Err(Error::new(ErrorKind::AlreadyExists, "reserved identity file collision")),
_ => {}
}
c.describe(USERS_TABLE).map(|schema| schema.system)
};
match registered {
@@ -893,6 +983,21 @@ impl Server {
if !self.table_path(USERS_TABLE).exists() {
return Err(Error::other("missing account data"));
}
let unified = self.catalog.lock().map_err(|_| Error::other("catalog lock poisoned"))?.describe(IDENTITIES_TABLE).is_some();
if unified {
let credentials = self.with_existing_table(USERS_TABLE, |t| t.scan())?;
let identities = self.with_existing_table(IDENTITIES_TABLE, |t| t.scan())?;
return identities.into_iter().map(|mut row| {
for field in ["can_login", "is_superuser", "enabled"] {
if row.get(field).and_then(serde_json::Value::as_bool).is_none() { return Err(Error::other("invalid identity attributes")); }
}
let id = row.get("id").and_then(serde_json::Value::as_i64).ok_or_else(|| Error::other("invalid identity record"))?;
let credential = credentials.iter().find(|r| r.get("id").and_then(serde_json::Value::as_i64) == Some(id))
.ok_or_else(|| Error::other("missing identity credential"))?;
row.insert("password_hash".into(), credential.get("password_hash").cloned().ok_or_else(|| Error::other("invalid identity credential"))?);
decode_account_row(&row)
}).collect();
}
let mut accounts: Vec<Account> = self
.with_existing_table(USERS_TABLE, |t| t.scan())?
.iter()
@@ -919,9 +1024,11 @@ impl Server {
// 表里已经有行就不再补人:引导程序只在空表上播种,
// 免得有人拿 storage 透传绕过账号接口往里塞一个已知口令的账号
if accounts.is_empty() && !accounts.iter().any(|a| a.user == name) {
let id = accounts.iter().map(|a| a.id).max().unwrap_or(0)
let id = accounts.iter().map(|a| a.id).max().unwrap_or(0).max(self.catalog.lock().map_err(|_| Error::other("catalog lock poisoned"))?.identity_high_water)
.checked_add(1).ok_or_else(|| Error::other("account id exhausted"))?;
accounts.push(new_account(id, &name, password_hash.unwrap_or_default().to_string())?);
let mut administrator = new_account(id, &name, password_hash.unwrap_or_default().to_string())?;
administrator.is_superuser = true;
accounts.push(administrator);
changed = true;
}
}
@@ -931,7 +1038,7 @@ impl Server {
for row in account_rows(&accounts)? {
HeapTable::encode_row(&row, self.cfg.page_size)?;
}
let op = WalOp::Accounts { accounts: accounts.clone() };
let op = WalOp::Accounts { accounts: accounts.clone(), removed: Vec::new(), migrate_roles: false };
self.recovery_required.store(true, Ordering::Release);
self.wal.truncate()?;
self.wal.append(&op)?;
@@ -952,17 +1059,93 @@ impl Server {
return Err(Error::other("system catalog is not initialized; run csql-bootstrap"));
}
let mut accounts = self.load_accounts()?;
let request = match request {
Request::CatalogManage { command } => {
let target = match command.as_ref() {
Request::AccountCreate { user, .. } | Request::AccountReset { user, .. } | Request::AccountDrop { user }
| Request::IdentityAlter { user, is_superuser: None, system_catalog_manager: None, .. } => Some(account_name(user)?),
Request::AccountsList => None,
_ => return Err(Error::other("superuser required for this catalog operation")),
};
if accounts.iter().any(|a| target.as_ref() == Some(&a.user) && (a.is_superuser || a.system_catalog_manager)) {
return Err(Error::other("superuser required to manage privileged identities"));
}
*command
}
other => other,
};
let mut names = std::collections::HashSet::new();
let mut ids = std::collections::HashSet::new();
for account in &mut accounts {
let name = account_name(&account.user)?;
if !names.insert(name.clone()) || account.id < 1 || !ids.insert(account.id) {
return Err(Error::other("invalid or duplicate identity record"));
}
if account.identity_version == 0 { account.user = name; }
}
let previous = accounts.clone();
let migrate_roles = matches!(request, Request::IdentityInitialize { .. });
if !migrate_roles && !matches!(request, Request::AccountsList) {
let legacy_exists = self.catalog.lock().map_err(|_| Error::other("catalog lock poisoned"))?.describe("__system_roles").is_some();
if legacy_exists && !self.with_existing_table("__system_roles", |t| t.scan())?.is_empty() {
return Err(Error::other("identity migration required"));
}
}
let had_superuser = accounts.iter().any(|a| a.enabled && a.can_login && a.is_superuser);
match request {
Request::AccountsList => return Ok(accounts),
Request::IdentityInitialize { administrator } => {
for account in &mut accounts {
if account.identity_version == 0 {
account.is_superuser = account.user == administrator.to_ascii_lowercase();
account.identity_version = 1;
}
}
let legacy_roles = "__system_roles";
let exists = self.catalog.lock().map_err(|_| Error::other("catalog lock poisoned"))?.describe(legacy_roles).is_some();
if exists {
for row in self.with_existing_table(legacy_roles, |t| t.scan())? {
let name = account_name(row.get("name").and_then(serde_json::Value::as_str).ok_or_else(|| Error::other("invalid legacy role"))?)?;
if accounts.iter().any(|a| a.user == name) { return Err(Error::other(format!("identity name collision: {name}"))); }
let id = accounts.iter().map(|a| a.id).max().unwrap_or(0).max(self.catalog.lock().map_err(|_| Error::other("catalog lock poisoned"))?.identity_high_water)
.checked_add(1).ok_or_else(|| Error::other("account id exhausted"))?;
let mut role = new_account(id, &name, String::new())?;
role.can_login = false;
accounts.push(role);
}
}
let unified = self.catalog.lock().map_err(|_| Error::other("catalog lock poisoned"))?.describe(IDENTITIES_TABLE).is_some();
if !accounts.is_empty() { require_superuser(&accounts)?; }
if unified && previous == accounts { return Ok(accounts); }
}
Request::AccountCreate { user, password_hash } => {
let name = account_name(&user)?;
if accounts.iter().any(|a| a.user == name) {
return Err(Error::new(ErrorKind::AlreadyExists, "account already exists"));
}
let id = accounts.iter().map(|a| a.id).max().unwrap_or(0)
let id = accounts.iter().map(|a| a.id).max().unwrap_or(0).max(self.catalog.lock().map_err(|_| Error::other("catalog lock poisoned"))?.identity_high_water)
.checked_add(1).ok_or_else(|| Error::other("account id exhausted"))?;
accounts.push(new_account(id, &name, password_hash)?);
}
Request::RoleCreate { user } => {
let name = account_name(&user)?;
if accounts.iter().any(|a| a.user == name) { return Err(Error::new(ErrorKind::AlreadyExists, "identity already exists")); }
let id = accounts.iter().map(|a| a.id).max().unwrap_or(0).max(self.catalog.lock().map_err(|_| Error::other("catalog lock poisoned"))?.identity_high_water)
.checked_add(1).ok_or_else(|| Error::other("account id exhausted"))?;
let mut role = new_account(id, &name, String::new())?;
role.can_login = false;
accounts.push(role);
}
Request::IdentityAlter { user, can_login, is_superuser, enabled, system_catalog_manager } => {
let name = account_name(&user)?;
let account = accounts.iter_mut().find(|a| a.user == name).ok_or_else(|| Error::new(ErrorKind::NotFound, "unknown account"))?;
account.can_login = can_login.unwrap_or(account.can_login);
account.is_superuser = is_superuser.unwrap_or(account.is_superuser);
account.enabled = enabled.unwrap_or(account.enabled);
account.system_catalog_manager = system_catalog_manager.unwrap_or(account.system_catalog_manager);
account.revision = account.revision.checked_add(1).ok_or_else(|| Error::other("account revision exhausted"))?;
if had_superuser { require_superuser(&accounts)?; }
}
Request::AccountReset { user, password_hash } => {
let name = account_name(&user)?;
validate_account_hash(&password_hash)?;
@@ -976,6 +1159,7 @@ impl Server {
let stamp = at.unwrap_or_else(now_stamp);
let account = accounts.iter_mut().find(|a| a.user == name)
.ok_or_else(|| Error::new(ErrorKind::NotFound, "unknown account"))?;
if !account.enabled || !account.can_login { return Err(Error::other("identity cannot login")); }
account.last_login_at = Some(stamp);
}
Request::AccountDrop { user } => {
@@ -985,13 +1169,15 @@ impl Server {
if accounts.len() == before {
return Err(Error::new(ErrorKind::NotFound, "unknown account"));
}
if had_superuser { require_superuser(&accounts)?; }
}
_ => return Err(Error::other("unexpected account request")),
}
for row in account_rows(&accounts)? {
for row in account_rows(&accounts)?.into_iter().chain(identity_rows(&accounts)?) {
HeapTable::encode_row(&row, self.cfg.page_size)?;
}
let op = WalOp::Accounts { accounts: accounts.clone() };
let removed = previous.iter().filter(|old| !accounts.iter().any(|a| a.user == old.user)).map(|a| a.user.clone()).collect();
let op = WalOp::Accounts { accounts: accounts.clone(), removed, migrate_roles };
self.recovery_required.store(true, Ordering::Release);
self.wal.truncate()?;
self.wal.append(&op)?;
@@ -1001,6 +1187,53 @@ impl Server {
Ok(accounts)
}
/// 初始化并校验内置类型目录
fn bootstrap_types(&self, types: Vec<Row>) -> std::io::Result<Vec<Row>> {
use std::io::Error;
let _guard = self.write_lock.lock().map_err(|_| Error::other("write lock poisoned"))?;
if !self.account_table_ready()? { return Err(Error::other("system catalog is not initialized; run csql-bootstrap")); }
require_superuser(&self.load_accounts()?)?;
let columns = types_columns();
let existing = self.catalog.lock().map_err(|_| Error::other("catalog lock poisoned"))?.describe(TYPES_TABLE).cloned();
if existing.as_ref().is_some_and(|s| !s.system || s.columns != columns) { return Err(Error::other("reserved type catalog collision")); }
if existing.is_none() && (self.table_path(TYPES_TABLE).exists() || self.index_path(TYPES_TABLE, "id").exists()) {
return Err(Error::other("reserved type file collision"));
}
let mut rows = if existing.is_some() { self.with_existing_table(TYPES_TABLE, |t| t.scan())? } else { Vec::new() };
let mut names = std::collections::HashSet::new();
let mut ids = std::collections::HashSet::new();
for row in &rows {
let name = type_definition_name(row)?;
let id = row.get("id").and_then(serde_json::Value::as_i64).ok_or_else(|| Error::other("invalid type id"))?;
if id < 1 || !ids.insert(id) || !names.insert(name.to_string()) { return Err(Error::other("duplicate type catalog record")); }
}
let original = rows.clone();
let mut requested = std::collections::HashSet::new();
for mut definition in types {
let name = type_definition_name(&definition)?.to_string();
if definition.len() != 3 || !requested.insert(name.clone()) { return Err(Error::other("invalid or duplicate preinstalled type")); }
if let Some(saved) = rows.iter().find(|row| row.get("name").and_then(serde_json::Value::as_str) == Some(&name)) {
if saved.get("base_type") != definition.get("base_type") || saved.get("parameterized") != definition.get("parameterized") {
return Err(Error::other(format!("preinstalled type definition conflict: {name}")));
}
} else {
let id = rows.iter().filter_map(|r| r.get("id").and_then(serde_json::Value::as_i64)).max().unwrap_or(0)
.checked_add(1).ok_or_else(|| Error::other("type id exhausted"))?;
definition.insert("id".into(), serde_json::json!(id));
HeapTable::encode_row(&definition, self.cfg.page_size)?;
rows.push(definition);
}
}
if existing.is_some() && original == rows { return Ok(rows); }
let mut ops = Vec::new();
if existing.is_none() { ops.push(WalOp::CreateTable { table: TYPES_TABLE.into(), columns }); }
ops.push(WalOp::ReplaceAll { table: TYPES_TABLE.into(), rows: rows.clone() });
self.recovery_required.store(true, Ordering::Release);
self.group_via_wal(&ops)?;
self.recovery_required.store(false, Ordering::Release);
Ok(rows)
}
/// 一条请求翻成一条响应:先过读/写门再分发
fn handle_request(&self, req: Request) -> Response {
if concurrent_read(&req) {
@@ -1037,7 +1270,12 @@ impl Server {
}
}
match req {
request @ (Request::AccountsList | Request::AccountCreate { .. }
Request::BootstrapTypes { types } => match self.bootstrap_types(types) {
Ok(rows) => Response::Rows { rows },
Err(e) => Response::Error { message: e.to_string() },
},
request @ (Request::AccountsList | Request::CatalogManage { .. } | Request::AccountCreate { .. }
| Request::IdentityInitialize { .. } | Request::RoleCreate { .. } | Request::IdentityAlter { .. }
| Request::AccountReset { .. } | Request::AccountLogin { .. }
| Request::AccountDrop { .. }) => {
match self.accounts_request(request) {
@@ -1458,12 +1696,45 @@ impl Server {
}
}
/// 构造内置类型目录列定义
fn types_columns() -> Vec<SchemaColumn> {
vec![
SchemaColumn { name: "id".into(), ty: "int".into(), nullable: false, primary_key: true, ..Default::default() },
SchemaColumn { name: "name".into(), ty: "varchar(64)".into(), nullable: false, unique: true, ..Default::default() },
SchemaColumn { name: "base_type".into(), ty: "varchar(64)".into(), nullable: false, ..Default::default() },
SchemaColumn { name: "parameterized".into(), ty: "bool".into(), nullable: false, ..Default::default() },
]
}
/// 校验类型定义并读取名称
fn type_definition_name(row: &Row) -> std::io::Result<&str> {
let name = row.get("name").and_then(serde_json::Value::as_str).ok_or_else(|| std::io::Error::other("invalid type name"))?;
let base = row.get("base_type").and_then(serde_json::Value::as_str).ok_or_else(|| std::io::Error::other("invalid type definition"))?;
if name.is_empty() || name.len() > 48 || !name.bytes().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == b'_')
|| base.is_empty() || base.len() > 64 || row.get("parameterized").and_then(serde_json::Value::as_bool).is_none() {
return Err(std::io::Error::other("invalid type definition"));
}
Ok(name)
}
/// 账号列表转成行
fn account_rows(accounts: &[Account]) -> std::io::Result<Vec<Row>> {
accounts.iter().map(|a| Ok(HashMap::from([
("id".into(), serde_json::json!(a.id)),
("user".into(), serde_json::Value::String(a.user.clone())),
("password_hash".into(), serde_json::Value::String(a.password_hash.clone())),
]))).collect()
}
/// 身份元数据转成行
fn identity_rows(accounts: &[Account]) -> std::io::Result<Vec<Row>> {
accounts.iter().map(|a| Ok(HashMap::from([
("id".into(), serde_json::json!(a.id)),
("user".into(), serde_json::Value::String(a.user.clone())),
("can_login".into(), serde_json::json!(a.can_login)),
("is_superuser".into(), serde_json::json!(a.is_superuser)),
("system_catalog_manager".into(), serde_json::json!(a.system_catalog_manager)),
("enabled".into(), serde_json::json!(a.enabled)),
("identity_version".into(), serde_json::json!(a.identity_version)),
("registered_at".into(), serde_json::Value::String(a.registered_at.clone())),
("last_login_at".into(), match &a.last_login_at {
Some(stamp) => serde_json::Value::String(stamp.clone()),
@@ -1478,10 +1749,23 @@ fn users_columns() -> Vec<SchemaColumn> {
vec![
SchemaColumn { name: "id".into(), ty: "int".into(), nullable: false,
auto_increment: true, primary_key: true, ..Default::default() },
SchemaColumn { name: "user".into(), ty: "varchar(64)".into(), nullable: false,
unique: true, ..Default::default() },
SchemaColumn { name: "password_hash".into(), ty: "varchar(256)".into(),
nullable: false, ..Default::default() },
]
}
/// 系统身份目录结构
fn identity_columns() -> Vec<SchemaColumn> {
vec![
SchemaColumn { name: "id".into(), ty: "int".into(), nullable: false,
primary_key: true, ..Default::default() },
SchemaColumn { name: "user".into(), ty: "varchar(64)".into(), nullable: false,
unique: true, ..Default::default() },
SchemaColumn { name: "can_login".into(), ty: "bool".into(), nullable: false, ..Default::default() },
SchemaColumn { name: "is_superuser".into(), ty: "bool".into(), nullable: false, ..Default::default() },
SchemaColumn { name: "system_catalog_manager".into(), ty: "bool".into(), nullable: false, ..Default::default() },
SchemaColumn { name: "enabled".into(), ty: "bool".into(), nullable: false, ..Default::default() },
SchemaColumn { name: "identity_version".into(), ty: "int".into(), nullable: false, ..Default::default() },
SchemaColumn { name: "registered_at".into(), ty: "timestamp".into(),
nullable: false, ..Default::default() },
SchemaColumn { name: "last_login_at".into(), ty: "timestamp".into(),
@@ -1497,7 +1781,6 @@ fn declare_system_schema(catalog: &mut Catalog, rows: &[Row]) -> std::io::Result
catalog.set_columns(USERS_TABLE, users_columns())?;
catalog.rebuild_stats(USERS_TABLE, rows);
catalog.mark_system(USERS_TABLE)?;
catalog.add_index(USERS_TABLE, "user")?;
Ok(())
}
@@ -1519,6 +1802,11 @@ fn decode_account_row(row: &Row) -> std::io::Result<Account> {
revision: row.get("revision").and_then(serde_json::Value::as_u64).unwrap_or(1),
registered_at: row.get("registered_at").and_then(serde_json::Value::as_str).unwrap_or("").to_string(),
last_login_at: row.get("last_login_at").and_then(serde_json::Value::as_str).map(str::to_string),
can_login: row.get("can_login").and_then(serde_json::Value::as_bool).unwrap_or(true),
is_superuser: row.get("is_superuser").and_then(serde_json::Value::as_bool).unwrap_or(false),
system_catalog_manager: row.get("system_catalog_manager").and_then(serde_json::Value::as_bool).unwrap_or(false),
enabled: row.get("enabled").and_then(serde_json::Value::as_bool).unwrap_or(true),
identity_version: row.get("identity_version").and_then(serde_json::Value::as_u64).unwrap_or(0) as u8,
})
}
@@ -1589,9 +1877,20 @@ fn new_account(id: i64, user: &str, password_hash: String) -> std::io::Result<Ac
revision: 1,
registered_at: now_stamp(),
last_login_at: None,
can_login: true,
is_superuser: false,
system_catalog_manager: false,
enabled: true,
identity_version: 1,
})
}
/// 保留至少一个启用的登录管理员
fn require_superuser(accounts: &[Account]) -> std::io::Result<()> {
if accounts.iter().any(|a| a.can_login && a.enabled && a.is_superuser) { Ok(()) }
else { Err(std::io::Error::other("the last enabled login superuser cannot be removed")) }
}
/// 一张表的索引定义转线上格式
fn index_wires(schema: &crate::catalog::TableSchema) -> Vec<IndexWire> {
let mut out = vec![IndexWire {
@@ -1632,6 +1931,40 @@ impl Drop for Server {
}
}
/// 按列精度规范化一行定点数
fn normalize_decimal_row(row: &mut Row, columns: &[SchemaColumn]) -> std::io::Result<()> {
for column in columns {
if let Some((precision, scale)) = decimal_parameters(&column.ty)?
&& let Some(value) = row.get_mut(&column.name).filter(|v| !v.is_null()) {
let number = value.as_f64().ok_or_else(|| std::io::Error::other("invalid decimal value"))?;
let factor = 10f64.powi(scale);
let scaled = number * factor;
if !scaled.is_finite() { return Err(std::io::Error::other("decimal value is not finite or exceeds precision")); }
let rounded = scaled.round_ties_even() / factor;
if rounded.abs() >= 10f64.powi(precision - scale) { return Err(std::io::Error::other("decimal value exceeds precision")); }
*value = serde_json::json!(rounded);
}
}
Ok(())
}
/// 解析基础或命名定点类型参数
fn decimal_parameters(ty: &str) -> std::io::Result<Option<(i32, i32)>> {
let mut base = ty.trim();
while let Some(body) = base.strip_prefix("domain(").and_then(|s| s.strip_suffix(')')) {
base = body.split_once(',').ok_or_else(|| std::io::Error::other("invalid domain type"))?.1.trim();
}
let normalized = crate::catalog::normalize_type(base);
if normalized == "decimal" { return Ok(Some((10, 0))); }
if !normalized.starts_with("decimal(") { return Ok(None); }
let error = || std::io::Error::other("invalid decimal precision or scale");
let body = normalized.strip_prefix("decimal(").and_then(|s| s.strip_suffix(')')).ok_or_else(error)?;
let values = body.split(',').map(|s| s.trim().parse::<i32>()).collect::<Result<Vec<_>, _>>().map_err(|_| error())?;
let (precision, scale) = match values.as_slice() { [p] => (*p, 0), [p, s] => (*p, *s), _ => return Err(error()) };
if !(1..=308).contains(&precision) || scale < 0 || scale > precision { return Err(error()); }
Ok(Some((precision, scale)))
}
/// 系统库名:账号表住在这里,其中的表都是系统表。服务启动时只建它一个,
/// 别的库一律要显式建(没有默认工作库)。
const SYSTEM_DATABASE: &str = "system";
@@ -1793,9 +2126,11 @@ impl Databases {
value["table"] = serde_json::Value::String(table.to_string());
}
let request: Request = serde_json::from_value(value).map_err(Error::other)?;
let global = matches!(request, Request::AccountsList | Request::AccountCreate { .. }
let global = matches!(request, Request::AccountsList | Request::CatalogManage { .. } | Request::AccountCreate { .. }
| Request::IdentityInitialize { .. } | Request::RoleCreate { .. } | Request::IdentityAlter { .. }
| Request::AccountReset { .. } | Request::AccountLogin { .. }
| Request::AccountDrop { .. } | Request::BootstrapSystem { .. }
| Request::BootstrapTypes { .. }
| Request::SystemStatus | Request::Ping);
if !global && selected.is_empty() { return Err(Error::other("no database selected")); }
let server = if global || selected == SYSTEM_DATABASE { Arc::clone(&self.system) }
@@ -1838,21 +2173,185 @@ fn log_config(loaded: &Loaded) {
}
}
/// 获取数据目录的跨进程独占锁
fn lock_data_directory(cfg: &Config) -> std::io::Result<std::fs::File> {
std::fs::create_dir_all(&cfg.data_dir)?;
let file = std::fs::OpenOptions::new().read(true).write(true).create(true).truncate(false)
.open(cfg.data_dir.join(".chusql.lock"))?;
file.try_lock().map_err(|e| std::io::Error::other(format!("data directory is in use; stop all database processes: {e}")))?;
Ok(file)
}
/// 复制系统目录并拒绝链接文件
fn copy_system_directory(source: &std::path::Path, target: &std::path::Path) -> std::io::Result<()> {
std::fs::create_dir_all(target)?;
for entry in std::fs::read_dir(source)? {
let entry = entry?;
let ty = entry.file_type()?;
if ty.is_symlink() { return Err(std::io::Error::other("system directory contains a symbolic link")); }
if ty.is_dir() { copy_system_directory(&entry.path(), &target.join(entry.file_name()))?; }
else if ty.is_file() { std::fs::copy(entry.path(), target.join(entry.file_name()))?; }
else { return Err(std::io::Error::other("unsupported system directory entry")); }
}
Ok(())
}
/// 构造可重建的系统表结构
fn maintenance_schemas() -> Vec<(&'static str, Vec<SchemaColumn>)> {
let text = |name: &str, size: usize| SchemaColumn { name: name.into(), ty: format!("varchar({size})"), ..Default::default() };
vec![(USERS_TABLE, users_columns()), (IDENTITIES_TABLE, identity_columns()), (TYPES_TABLE, types_columns()),
("__system_roles", vec![text("name", 64)]),
("__system_grants", vec![text("role", 64), text("privilege", 16), text("object", 128)]),
("__system_grant_options", vec![text("role", 64), text("privilege", 16), text("object", 128)]),
("__system_members", vec![text("role", 64), text("member", 64)])]
}
/// 验证隔离副本并执行所选恢复策略
fn maintenance_candidate(cfg: Config, mode: &str, user: &str, hash: &str, types: Vec<Row>) -> Result<(), String> {
if mode == "reset" {
let seed = Server::new(cfg.clone()).map_err(|e| e.to_string())?;
seed.bootstrap_system(Some(user), Some(hash)).map_err(|e| e.to_string())?;
seed.bootstrap_types(types.clone()).map_err(|e| e.to_string())?;
}
let repair = mode == "repair";
let catalog_path = cfg.data_dir.join("catalog.json");
let schemas = maintenance_schemas();
let mut missing_data = Vec::new();
let mut catalog = match Catalog::load(&catalog_path) {
Ok(catalog) => catalog,
Err(_) => {
for entry in std::fs::read_dir(&cfg.data_dir).map_err(|e| e.to_string())? {
let entry = entry.map_err(|e| e.to_string())?;
if entry.path().extension().and_then(|s| s.to_str()) == Some("db") {
let name = entry.path().file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default();
if !schemas.iter().any(|(known, _)| *known == name) { return Err(format!("cannot reconstruct unknown system table schema: {name}")); }
}
}
Catalog::default()
}
};
if catalog.all_tables().is_empty() {
for entry in std::fs::read_dir(&cfg.data_dir).map_err(|e| e.to_string())? {
let path = entry.map_err(|e| e.to_string())?.path();
if path.extension().and_then(|s| s.to_str()) == Some("db") {
let name = path.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default();
if !schemas.iter().any(|(known, _)| *known == name) { return Err(format!("cannot reconstruct unknown system table schema: {name}")); }
}
}
}
for (name, columns) in &schemas {
let file = cfg.data_dir.join(format!("{name}.db"));
if let Some(schema) = catalog.describe(name).filter(|s| !file.exists() && s.row_count > 0) {
missing_data.push((*name, schema.row_count));
}
if repair && !file.exists() && catalog.describe(name).is_none_or(|s| s.row_count != 0) {
return Err(format!("repair would require initializing or recovering data in {name}"));
}
if catalog.describe(name).is_none() { catalog.create_table(name, columns.clone()).map_err(|e| e.to_string())?; }
else { catalog.set_columns(name, columns.clone()).map_err(|e| e.to_string())?; }
catalog.mark_system(name).map_err(|e| e.to_string())?;
}
catalog.save(&catalog_path).map_err(|e| e.to_string())?;
let server = Server::new(cfg).map_err(|e| e.to_string())?;
if repair {
let scan = server.wal.read_all().map_err(|e| e.to_string())?;
let checkpoint = server.wal.read_checkpoint().map_err(|e| e.to_string())?.unwrap_or(0);
if !committed_ops(&records_after(&scan.records, checkpoint)).is_empty() { return Err("repair would require replaying WAL data".into()); }
} else {
server.recover_wal().map_err(|e| e.to_string())?;
}
for (name, _) in &schemas { server.with_table(name, |t| t.flush()).map_err(|e| e.to_string())?; }
if !repair {
if mode == "recover" && server.load_accounts().map_err(|e| e.to_string())?.is_empty() && hash.is_empty() {
return Err("recover requires --password-stdin to initialize a missing administrator".into());
}
server.bootstrap_system(Some(user), Some(hash)).map_err(|e| e.to_string())?;
server.accounts_request(Request::IdentityInitialize { administrator: user.into() }).map_err(|e| e.to_string())?;
server.bootstrap_types(types.clone()).map_err(|e| e.to_string())?;
}
let accounts = server.load_accounts().map_err(|e| e.to_string())?;
require_superuser(&accounts).map_err(|e| e.to_string())?;
for (name, expected) in missing_data {
let rows = server.with_existing_table(name, |t| t.scan()).map_err(|e| e.to_string())?;
if (rows.len() as u64) < expected { return Err(format!("WAL cannot reconstruct all missing data in {name}; restore a backup or use reset")); }
}
if repair {
let saved = server.with_existing_table(TYPES_TABLE, |t| t.scan()).map_err(|e| e.to_string())?;
if types.iter().any(|wanted| !saved.iter().any(|row| wanted.iter().all(|(key, value)| row.get(key) == Some(value)))) {
return Err("repair would require changing preinstalled type data".into());
}
}
for (name, _) in &schemas { server.with_existing_table(name, |t| t.scan()).map_err(|e| e.to_string())?; }
{
let mut catalog = server.catalog.lock().map_err(|_| "catalog lock poisoned")?;
catalog.identity_high_water = catalog.identity_high_water.max(accounts.iter().map(|a| a.id).max().unwrap_or(0));
catalog.save(server.catalog_path()).map_err(|e| e.to_string())?;
}
server.rebuild_stats().map_err(|e| e.to_string())?;
server.flush_all_tables().map_err(|e| e.to_string())?;
Ok(())
}
/// 进程内存储句柄:一个数据目录一份状态,跨线程共享(内部各自加锁)。
pub struct Storage {
databases: Databases,
_directory_lock: std::fs::File,
}
impl Storage {
/// 离线修复或重建系统目录
pub fn maintenance(config_path: Option<&str>, request: &str) -> Result<String, String> {
let loaded = config::load(config_path)?;
let cfg = loaded.config;
let _lock = lock_data_directory(&cfg).map_err(|e| e.to_string())?;
let input: serde_json::Value = serde_json::from_str(request).map_err(|e| e.to_string())?;
let mode = input.get("mode").and_then(|v| v.as_str()).ok_or("missing maintenance mode")?;
if !["repair", "recover", "reset"].contains(&mode) { return Err("invalid maintenance mode".into()); }
let user = input.get("user").and_then(|v| v.as_str()).unwrap_or("root");
let hash = input.get("password_hash").and_then(|v| v.as_str()).unwrap_or("");
let types: Vec<Row> = serde_json::from_value(input.get("types").cloned().ok_or("missing type manifest")?).map_err(|e| e.to_string())?;
if types.is_empty() { return Err("type manifest must not be empty".into()); }
for row in &types { type_definition_name(row).map_err(|e| e.to_string())?; }
let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_err(|e| e.to_string())?.as_nanos();
let system = cfg.data_dir.join("system");
if system.is_symlink() { return Err("system directory cannot be a symbolic link".into()); }
let work = cfg.data_dir.join(format!("bootstrap-work-{stamp}"));
let backup = cfg.data_dir.join(format!("system-backup-{stamp}"));
let staged = work.join("system");
std::fs::create_dir_all(&staged).map_err(|e| e.to_string())?;
if mode != "reset" && system.exists() { copy_system_directory(&system, &staged).map_err(|e| e.to_string())?; }
let mut candidate_cfg = cfg.clone();
candidate_cfg.data_dir = staged.clone();
let result = maintenance_candidate(candidate_cfg, mode, user, hash, types);
if let Err(err) = result { return Err(format!("{err}; original system directory was not changed; candidate: {}. Try csql-bootstrap recover or reset", work.display())); }
if mode == "repair" && system.exists() {
for entry in std::fs::read_dir(&system).map_err(|e| e.to_string())? {
let entry = entry.map_err(|e| e.to_string())?;
if entry.path().extension().and_then(|s| s.to_str()) == Some("db")
&& std::fs::read(entry.path()).map_err(|e| e.to_string())? != std::fs::read(staged.join(entry.file_name())).map_err(|e| e.to_string())? {
return Err("repair would modify table data; original system directory was not changed. Use recover or reset".into());
}
}
}
if system.exists() { std::fs::rename(&system, &backup).map_err(|e| e.to_string())?; }
if let Err(err) = std::fs::rename(&staged, &system) {
if backup.exists() { std::fs::rename(&backup, &system).map_err(|rollback| format!("publish failed: {err}; rollback failed: {rollback}; backup: {}", backup.display()))?; }
return Err(format!("cannot publish repaired system directory: {err}"));
}
serde_json::to_string(&serde_json::json!({"status":"maintenance", "mode":mode, "backup":backup, "candidate":work,
"message":"System maintenance completed. Business databases were preserved."})).map_err(|e| e.to_string())
}
/// 打开数据目录并初始化存储
pub fn open(config_path: Option<&str>) -> Result<Storage, String> {
let loaded = config::load(config_path)?;
let directory_lock = lock_data_directory(&loaded.config).map_err(|e| e.to_string())?;
log::init();
log::set_level(loaded.config.log_level);
log_info!(core, "chusql-core-storage {} starting", env!("CARGO_PKG_VERSION"));
log_config(&loaded);
let databases = Databases::new(loaded.config).map_err(|e| e.to_string())?;
Ok(Storage { databases })
Ok(Storage { databases, _directory_lock: directory_lock })
}
/// 一行请求 JSON 换一行响应 JSON
+23 -13
View File
@@ -31,7 +31,7 @@ const OP_DROP_INDEX: u8 = 14;
pub enum WalOp {
HideColumn { table: String, column: String },
Compact { table: String, rows: Vec<Row> },
Accounts { accounts: Vec<Account> },
Accounts { accounts: Vec<Account>, removed: Vec<String>, migrate_roles: bool },
Insert {
table: String,
row: Row,
@@ -283,7 +283,7 @@ impl Wal {
frames.push((lsn, op, offset, end));
offset = end;
}
Err(_) => break,
Err(error) => return Err(io::Error::new(io::ErrorKind::InvalidData, error)),
}
}
Ok((frames, offset))
@@ -291,9 +291,13 @@ impl Wal {
/// 记录检查点:到此为止的 LSN 都已落盘
pub fn write_checkpoint(&self, lsn: u64) -> io::Result<()> {
let mut f = File::create(self.checkpoint_path())?;
let checkpoint = self.checkpoint_path();
let pending = checkpoint.with_extension("checkpoint.pending");
let mut f = File::create(&pending)?;
f.write_all(&lsn.to_le_bytes())?;
f.sync_all()
f.sync_all()?;
drop(f);
std::fs::rename(pending, checkpoint)
}
/// 读检查点 LSN
@@ -303,8 +307,8 @@ impl Wal {
return Ok(None);
}
let bytes = std::fs::read(&path)?;
if bytes.len() < 8 {
return Ok(None);
if bytes.len() != 8 {
return Err(io::Error::new(io::ErrorKind::InvalidData, "invalid WAL checkpoint length"));
}
let mut raw = [0u8; 8];
raw.copy_from_slice(&bytes[..8]);
@@ -322,10 +326,10 @@ impl Wal {
if let Some((lsn, _, _, _)) = frames.last() {
base = base.max(*lsn);
}
*slot = base + 1;
*slot = base.checked_add(1).ok_or_else(|| io::Error::other("WAL LSN exhausted"))?;
}
let lsn = *slot;
*slot += 1;
*slot = slot.checked_add(1).ok_or_else(|| io::Error::other("WAL LSN exhausted"))?;
Ok(lsn)
}
@@ -378,8 +382,8 @@ fn encode(op: &WalOp, lsn: u64) -> io::Result<Vec<u8>> {
let p = serde_json::to_vec(rows).map_err(io::Error::other)?;
(OP_COMPACT, table.clone(), p)
}
WalOp::Accounts { accounts } => (OP_ACCOUNTS, USERS_TABLE.to_string(),
serde_json::to_vec(accounts).map_err(io::Error::other)?),
WalOp::Accounts { accounts, removed, migrate_roles } => (OP_ACCOUNTS, USERS_TABLE.to_string(),
serde_json::to_vec(&serde_json::json!({"accounts":accounts,"removed":removed,"migrate_roles":migrate_roles})).map_err(io::Error::other)?),
WalOp::Insert { table, row } => {
let p = serde_json::to_vec(row).map_err(io::Error::other)?;
(OP_INSERT, table.clone(), p)
@@ -478,9 +482,15 @@ fn decode_body(body: &[u8]) -> Result<(u64, WalOp), String> {
}
OP_ACCOUNTS => {
if table != USERS_TABLE { return Err("invalid account WAL table".into()); }
let accounts = serde_json::from_slice(payload)
.map_err(|e| format!("bad account payload: {e}"))?;
WalOp::Accounts { accounts }
let value: serde_json::Value = serde_json::from_slice(payload).map_err(|e| format!("bad account payload: {e}"))?;
let (accounts, removed, migrate_roles) = if value.is_array() {
(serde_json::from_value(value).map_err(|e| format!("bad account payload: {e}"))?, Vec::new(), false)
} else {
(serde_json::from_value(value.get("accounts").cloned().ok_or("missing account payload")?).map_err(|e| format!("bad account payload: {e}"))?,
serde_json::from_value(value.get("removed").cloned().ok_or("missing identity cleanup")?).map_err(|e| format!("bad account cleanup: {e}"))?,
value.get("migrate_roles").and_then(serde_json::Value::as_bool).ok_or("missing role migration flag")?)
};
WalOp::Accounts { accounts, removed, migrate_roles }
}
OP_INSERT => {
let row: Row =
+30
View File
@@ -124,6 +124,36 @@ fn stats_track_a_histogram_for_numeric_columns() {
assert_eq!(id.hist.iter().sum::<u64>(), 0);
}
/// 重复值删除保留仍存在的不同值计数
#[test]
fn duplicate_deletion_keeps_distinct_until_last_occurrence() {
let mut catalog = Catalog::default();
let duplicate = row(&[("id", json!(1)), ("v", json!(7))]);
catalog.rebuild_stats("t", &[duplicate.clone(), duplicate.clone()]);
catalog.record_delete("t", std::slice::from_ref(&duplicate));
assert_eq!(catalog.describe("t").unwrap().stats["v"].distinct, 1);
catalog.record_delete("t", &[duplicate]);
assert_eq!(catalog.describe("t").unwrap().stats["v"].distinct, 0);
}
/// 去重封顶不影响直方图和其他列
#[test]
fn capped_distinct_keeps_histogram_and_other_columns_accurate() {
let mut catalog = Catalog::default();
let rows: Vec<Row> = (0..5000).map(|n| row(&[("id", json!(n)), ("v", json!(n % 2))])).collect();
catalog.rebuild_stats("t", &rows);
let schema = catalog.describe("t").unwrap();
assert!(schema.stats["id"].capped);
assert_eq!(schema.stats["id"].hist.iter().sum::<u64>(), 5000);
assert_eq!(schema.stats["v"].distinct, 2);
catalog.record_delete("t", &rows[..1000]);
let schema = catalog.describe("t").unwrap();
assert_eq!(schema.stats["id"].hist.iter().sum::<u64>(), 4000);
assert_eq!(schema.stats["v"].distinct, 2);
catalog.rebuild_stats("t", &rows[1000..]);
assert_eq!(catalog.describe("t").unwrap().stats["id"].distinct, 4000);
}
/// 老类型名并到新写法,参数保留,未知类型原样
#[test]
fn normalize_type_maps_legacy_names() {
+21 -8
View File
@@ -1,7 +1,9 @@
use std::path::PathBuf;
use std::sync::Mutex;
use chusql_core_storage::config::{self, Config, DEFAULT_BTREE_ORDER, DEFAULT_PAGE_SIZE, Origin};
use chusql_core_storage::config::{
self, Config, DEFAULT_BTREE_ORDER, DEFAULT_LOG_FILES, DEFAULT_PAGE_SIZE, Origin,
};
use chusql_core_storage::log::Level;
// 配置测试:默认值、文件覆盖、环境变量彻底无效、非法值报错。
@@ -25,6 +27,7 @@ fn defaults_without_file() {
assert_eq!(loaded.config.page_size, DEFAULT_PAGE_SIZE);
assert_eq!(loaded.config.btree_order, DEFAULT_BTREE_ORDER);
assert_eq!(loaded.config.data_dir, config::default_data_dir());
assert_eq!(loaded.config.log_files, PathBuf::from(DEFAULT_LOG_FILES));
assert_eq!(loaded.config.log_level, Level::Info);
}
@@ -39,6 +42,7 @@ fn file_overrides_only_written_keys() {
assert_eq!(loaded.config.page_size, 8192);
assert_eq!(loaded.config.btree_order, DEFAULT_BTREE_ORDER);
assert_eq!(loaded.config.data_dir, config::default_data_dir());
assert_eq!(loaded.config.log_files, PathBuf::from(DEFAULT_LOG_FILES));
assert_eq!(loaded.config_path, Some(path.clone()));
let page = loaded.origins.iter().find(|(n, _, _)| *n == "page.size").unwrap();
@@ -47,11 +51,11 @@ fn file_overrides_only_written_keys() {
assert_eq!(order.2, Origin::Default);
}
/// 五项全写都生效
/// 全部配置项及其来源生效
#[test]
fn file_all_keys() {
let _guard = env_guard();
let text = "[page]\nsize = 8192\n[btree]\norder = 32\n[buffer]\npool_size = 128\n[storage]\ndata_dir = \"mydata\"\n[log]\nlevel = \"debug\"\n";
let text = "[page]\nsize = 8192\n[btree]\norder = 32\n[buffer]\npool_size = 128\n[storage]\ndata_dir = \"mydata\"\nlog_files = \"mylogs\"\n[log]\nlevel = \"debug\"\n";
let path = PathBuf::from("t.toml");
let loaded = config::resolve(Some(text), Some(path.clone())).unwrap();
@@ -59,6 +63,7 @@ fn file_all_keys() {
assert_eq!(loaded.config.btree_order, 32);
assert_eq!(loaded.config.pool_size, 128);
assert_eq!(loaded.config.data_dir, PathBuf::from("mydata"));
assert_eq!(loaded.config.log_files, PathBuf::from("mylogs"));
assert_eq!(loaded.config.log_level, Level::Debug);
assert!(
loaded
@@ -103,16 +108,16 @@ fn env_vars_are_ignored_entirely() {
fn default_config_path_follows_system_convention() {
let _guard = env_guard();
let path = config::default_config_path();
assert_eq!(path.file_name().unwrap(), "chusql.toml");
assert_eq!(path.file_name().unwrap(), "settings.toml");
assert_eq!(path.parent().unwrap().file_name().unwrap(), "ChuSQL");
assert!(path.ends_with(format!("ChuSQL{}chusql.toml", std::path::MAIN_SEPARATOR)), "got {}", path.display());
assert!(path.ends_with(format!("ChuSQL{}settings.toml", std::path::MAIN_SEPARATOR)), "got {}", path.display());
// 本机 APPDATA 存在时必须是 %APPDATA%\ChuSQL\chusql.toml
// 本机 APPDATA 存在时必须是 %APPDATA%\ChuSQL\settings.toml
#[cfg(windows)]
if let Some(appdata) = std::env::var_os("APPDATA") {
assert_eq!(
path,
PathBuf::from(appdata).join("ChuSQL").join("chusql.toml")
PathBuf::from(appdata).join("ChuSQL").join("settings.toml")
);
}
}
@@ -186,7 +191,7 @@ fn load_without_explicit_path_uses_builtin_defaults() {
#[test]
fn server_section_belongs_to_another_layer() {
let _guard = env_guard();
let db_server = "[server]\nhost = \"0.0.0.0\"\nport = 7778\nmax_rows = 10\n";
let db_server = "[server]\nlisten_host = \"0.0.0.0\"\nport = 7778\nmax_rows = 10\n";
let loaded = config::resolve(Some(db_server), None).unwrap();
assert_eq!(loaded.config.data_dir, config::default_data_dir());
@@ -226,6 +231,14 @@ fn rejects_unknown_key() {
assert!(err.contains("bad config"), "got {}", err);
}
/// 空的日志目录要报错(否则日志会落到当前目录)
#[test]
fn rejects_empty_log_files() {
let _guard = env_guard();
let err = config::resolve(Some("[storage]\nlog_files = \" \"\n"), None).unwrap_err();
assert!(err.contains("storage.log_files"), "got {}", err);
}
/// 坏的页面尺寸只能来自文件
#[test]
fn rejects_bad_page_size_from_file() {
+434 -5
View File
@@ -10,6 +10,433 @@ thread_local! {
static CURRENT: RefCell<Option<Arc<Storage>>> = const { RefCell::new(None) };
}
/// 定点写入检查舍入和整批原子拒绝
#[test]
fn decimal_writes_enforce_precision_before_wal() -> Result<(), Box<dyn std::error::Error>> {
let (_server, data) = start_server();
let mut c = connect()?;
request_ok(&mut c, serde_json::json!({"method":"create_table","table":"amounts","columns":[{"name":"id","ty":"int"},{"name":"value","ty":"decimal(4,2)"}]}))?;
request_ok(&mut c, serde_json::json!({"method":"insert","table":"amounts","row":{"id":1,"value":99.994}}))?;
let snapshot = send(&mut c, r#"{"method":"scan","table":"amounts"}"#);
let rows: serde_json::Value = serde_json::from_str(&snapshot)?;
assert_eq!(rows["rows"][0]["value"], serde_json::json!(99.99));
let checkpoint = std::fs::read(data.path().join("databases/main/wal.checkpoint"))?;
for value in [99.995, -99.995, 100.0] {
let response = send(&mut c, &serde_json::json!({"method":"insert_batch","table":"amounts","rows":[{"id":2,"value":1.2},{"id":3,"value":value}]}).to_string());
assert!(response.contains("exceeds precision"), "{response}");
let remaining: serde_json::Value = serde_json::from_str(&send(&mut c, r#"{"method":"scan","table":"amounts"}"#))?;
assert_eq!(remaining, rows);
assert_eq!(std::fs::read(data.path().join("databases/main/wal.checkpoint"))?, checkpoint);
}
let invalid = send(&mut c, &serde_json::json!({"method":"create_table","table":"invalid","columns":[{"name":"value","ty":"decimal(2,3)"}]}).to_string());
assert!(invalid.contains("invalid decimal"), "{invalid}");
Ok(())
}
/// 检查点失败封闭请求并在重启恢复
#[test]
fn checkpoint_failure_requires_recovery_and_preserves_committed_rows() -> Result<(), Box<dyn std::error::Error>> {
let (server, data) = start_server();
let mut c = connect()?;
request_ok(&mut c, serde_json::json!({"method":"create_table","table":"checkpointed","columns":[{"name":"id","ty":"int"}]}))?;
let pending = data.path().join("databases/main/wal.checkpoint.pending");
std::fs::create_dir(&pending)?;
let failed = send(&mut c, r#"{"method":"insert","table":"checkpointed","row":{"id":1}}"#);
assert!(failed.contains("error"), "{failed}");
assert!(send(&mut c, r#"{"method":"scan","table":"checkpointed"}"#).contains("recovery required"));
assert!(std::fs::metadata(data.path().join("databases/main/wal.log"))?.len() > 0);
drop(c);
drop(server);
std::fs::remove_dir(pending)?;
let _restarted = restart_at(data.path())?;
let mut c = connect()?;
let rows: serde_json::Value = serde_json::from_str(&send(&mut c, r#"{"method":"scan","table":"checkpointed"}"#))?;
assert_eq!(rows["rows"], serde_json::json!([{"id":1}]));
Ok(())
}
/// 应用失败封闭读写并重放完整提交组
#[test]
fn committed_apply_failures_require_recovery() -> Result<(), Box<dyn std::error::Error>> {
for operation in [
serde_json::json!({"method":"create_table","table":"created","columns":[{"name":"id","ty":"int"}]}),
serde_json::json!({"method":"replace_all","table":"items","rows":[{"id":2}]}),
serde_json::json!({"method":"apply_transaction","ops":[{"op":"delete","table":"items","ids":[1]},{"op":"replace","table":"items","rows":[{"id":2}]}]}),
] {
let (server, data) = start_server();
let mut c = connect()?;
request_ok(&mut c, serde_json::json!({"method":"create_table","table":"items","columns":[{"name":"id","ty":"int"}]}))?;
request_ok(&mut c, serde_json::json!({"method":"insert","table":"items","row":{"id":1}}))?;
let directory = data.path().join("databases/main");
let checkpoint = std::fs::read(directory.join("wal.checkpoint"))?;
let pending = directory.join("catalog.json.pending");
std::fs::create_dir(&pending)?;
let failed = send(&mut c, &operation.to_string());
assert!(failed.contains("error"), "{operation}: {failed}");
for request in [
r#"{"method":"scan","table":"items"}"#,
r#"{"method":"insert","table":"items","row":{"id":3}}"#,
] {
let blocked = send(&mut c, request);
assert!(blocked.contains("recovery required"), "{blocked}");
}
assert_eq!(std::fs::read(directory.join("wal.checkpoint"))?, checkpoint);
assert!(std::fs::metadata(directory.join("wal.log"))?.len() > 0);
drop(c);
drop(server);
std::fs::remove_dir(pending)?;
let restarted = restart_at(data.path())?;
let mut c = connect()?;
let table = if operation["method"] == "create_table" { "created" } else { "items" };
let result: serde_json::Value = serde_json::from_str(&send(&mut c, &serde_json::json!({"method":"scan","table":table}).to_string()))?;
let expected = if table == "created" { serde_json::json!([]) } else { serde_json::json!([{"id":2}]) };
assert_eq!(result["rows"], expected, "{operation}");
assert_eq!(std::fs::metadata(directory.join("wal.log"))?.len(), 0);
drop(c);
drop(restarted);
}
Ok(())
}
/// 首帧撕裂恢复后新日志仍可重放
#[test]
fn torn_first_frame_is_removed_before_new_writes() -> Result<(), Box<dyn std::error::Error>> {
let (server, data) = start_server();
let mut c = connect()?;
request_ok(&mut c, serde_json::json!({"method":"create_table","table":"torn","columns":[{"name":"id","ty":"int"}]}))?;
drop(c);
drop(server);
std::fs::write(data.path().join("databases/main/wal.log"), [20, 0, 0, 0, 1])?;
let restarted = restart_at(data.path())?;
assert_eq!(std::fs::metadata(data.path().join("databases/main/wal.log"))?.len(), 0);
let mut c = connect()?;
request_ok(&mut c, serde_json::json!({"method":"insert","table":"torn","row":{"id":1}}))?;
drop(c);
drop(restarted);
let _again = restart_at(data.path())?;
let mut c = connect()?;
let rows: serde_json::Value = serde_json::from_str(&send(&mut c, r#"{"method":"scan","table":"torn"}"#))?;
assert_eq!(rows["rows"], serde_json::json!([{"id":1}]));
Ok(())
}
/// 校验预装类型幂等与冲突保护
#[test]
fn preinstalled_types_are_idempotent_hidden_and_conflict_safe() -> Result<(), Box<dyn std::error::Error>> {
let (_server, data) = start_server();
let mut c = connect()?;
let premature = send(&mut c, &type_seed_request().to_string());
assert!(premature.contains("login superuser"), "{premature}");
send(&mut c, r#"{"method":"bootstrap_system","user":"root","password_hash":"original-hash"}"#);
let accounts = send(&mut c, r#"{"method":"accounts_list"}"#);
let request = type_seed_request();
let seeded: serde_json::Value = serde_json::from_str(&send(&mut c, &request.to_string()))?;
assert_eq!(seeded["status"], "rows", "{seeded}");
assert_eq!(seeded["rows"].as_array().map(Vec::len), Some(2));
assert_eq!(serde_json::from_str::<serde_json::Value>(&send(&mut c, &request.to_string()))?, seeded);
let mut conflict = request;
conflict["types"][1]["base_type"] = "int".into();
let rejected = send(&mut c, &conflict.to_string());
assert!(rejected.contains("definition conflict: text"), "{rejected}");
assert_eq!(serde_json::from_str::<serde_json::Value>(&send(&mut c, &type_seed_request().to_string()))?, seeded);
assert_eq!(send(&mut c, r#"{"method":"accounts_list"}"#), accounts);
let catalog: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(data.path().join("system/catalog.json"))?)?;
assert_eq!(catalog["tables"]["__system_types"]["system"], true);
assert_eq!(catalog["tables"]["__system_types"]["row_count"], 2);
let denied = send(&mut c, r#"{"method":"scan","database":"system","table":"__system_types"}"#);
assert!(denied.contains("reserved system table"), "{denied}");
Ok(())
}
/// 类型预装失败保留日志并在重启后恢复
#[test]
fn preinstalled_types_recover_after_catalog_write_failure() -> Result<(), Box<dyn std::error::Error>> {
let (server, data) = start_server();
let mut c = connect()?;
send(&mut c, r#"{"method":"bootstrap_system","user":"root","password_hash":"hash"}"#);
std::fs::create_dir(data.path().join("system/catalog.json.pending"))?;
let failed = send(&mut c, &type_seed_request().to_string());
assert!(failed.contains("error"), "{failed}");
assert!(std::fs::metadata(data.path().join("system/wal.log"))?.len() > 0);
assert!(send(&mut c, &type_seed_request().to_string()).contains("recovery required"));
drop(c);
drop(server);
std::fs::remove_dir(data.path().join("system/catalog.json.pending"))?;
let _restarted = restart_at(data.path())?;
let mut c = connect()?;
let restored: serde_json::Value = serde_json::from_str(&send(&mut c, &type_seed_request().to_string()))?;
assert_eq!(restored["rows"].as_array().map(Vec::len), Some(2), "{restored}");
Ok(())
}
/// 类型目录不覆盖未登记的现有文件
#[test]
fn preinstalled_types_reject_reserved_file_collision() -> Result<(), Box<dyn std::error::Error>> {
let (_server, data) = start_server();
let mut c = connect()?;
send(&mut c, r#"{"method":"bootstrap_system","user":"root","password_hash":"hash"}"#);
let path = data.path().join("system/__system_types.db");
std::fs::write(&path, b"preserved")?;
let rejected = send(&mut c, &type_seed_request().to_string());
assert!(rejected.contains("reserved type file collision"), "{rejected}");
assert_eq!(std::fs::read(path)?, b"preserved");
Ok(())
}
/// 生成两项预装类型定义
fn type_seed_request() -> serde_json::Value {
serde_json::json!({"method":"bootstrap_types","types":[
{"name":"int","base_type":"int","parameterized":false},
{"name":"text","base_type":"str","parameterized":false}
]})
}
/// 引导保留已用身份编号
#[test]
fn bootstrap_respects_previous_identity_ids() -> Result<(), Box<dyn std::error::Error>> {
let (_server, _data) = start_server();
let mut c = connect()?;
send(&mut c, r#"{"method":"account_create","user":"alice","password_hash":"hash"}"#);
let deleted: serde_json::Value = serde_json::from_str(&send(&mut c, r#"{"method":"account_drop","user":"alice"}"#))?;
assert_eq!(deleted["accounts"], serde_json::json!([]));
send(&mut c, r#"{"method":"bootstrap_system","user":"root","password_hash":"hash"}"#);
let seeded: serde_json::Value = serde_json::from_str(&send(&mut c, r#"{"method":"accounts_list"}"#))?;
assert_eq!(seeded["accounts"][0]["id"], 2);
Ok(())
}
/// 旧账号日志迁移只提升初始管理员
#[test]
fn legacy_account_wal_migrates_without_promoting_other_names() -> Result<(), Box<dyn std::error::Error>> {
use chusql_core_storage::wal::Wal;
let (server, data) = start_server();
let mut c = connect()?;
request_ok(&mut c, serde_json::json!({"method":"create_table","database":"system","table":"__system_roles","columns":[{"name":"name","ty":"str"}]}))?;
request_ok(&mut c, serde_json::json!({"method":"insert","database":"system","table":"__system_roles","row":{"name":"reader"}}))?;
drop(c);
drop(server);
let path = data.path().join("system/wal.log");
let lsn = Wal::new(&path).read_checkpoint()?.unwrap_or(0) + 1;
let legacy = serde_json::json!([
{"id":7,"user":"owner","password_hash":"owner-hash","revision":3,"registered_at":"2020-01-01 00:00:00","last_login_at":null},
{"id":9,"user":"alice","password_hash":"alice-hash","revision":2,"registered_at":"2020-01-02 00:00:00","last_login_at":null}
]);
let table = b"__system_users";
let mut body = lsn.to_le_bytes().to_vec();
body.push(6);
body.extend_from_slice(&(table.len() as u16).to_le_bytes());
body.extend_from_slice(table);
body.extend_from_slice(&serde_json::to_vec(&legacy)?);
let mut frame = (body.len() as u32).to_le_bytes().to_vec();
frame.extend_from_slice(&body);
std::fs::write(&path, frame)?;
let _restarted = restart_at(data.path())?;
let mut c = connect()?;
let wrong = send(&mut c, r#"{"method":"identity_initialize","administrator":"missing"}"#);
assert!(wrong.contains("last enabled login superuser"), "{wrong}");
let migrated: serde_json::Value = serde_json::from_str(&send(&mut c, r#"{"method":"identity_initialize","administrator":"owner"}"#))?;
assert_eq!(migrated["status"], "accounts", "{migrated}");
assert_eq!(migrated["accounts"][0]["id"], 7);
assert_eq!(migrated["accounts"][0]["is_superuser"], true);
assert_eq!(migrated["accounts"][0]["password_hash"], "owner-hash");
assert_eq!(migrated["accounts"][1]["is_superuser"], false);
assert_eq!(migrated["accounts"][2]["user"], "reader");
assert_eq!(migrated["accounts"][2]["id"], 10);
assert_eq!(serde_json::from_str::<serde_json::Value>(&send(&mut c, r#"{"method":"identity_initialize","administrator":"alice"}"#))?, migrated);
Ok(())
}
/// 身份属性重启保留且禁止删除最后管理员
#[test]
fn identity_attributes_survive_restart_and_protect_superuser() -> Result<(), Box<dyn std::error::Error>> {
let (server, data) = start_server();
let mut c = connect()?;
send(&mut c, r#"{"method":"bootstrap_system","user":"root","password_hash":"hash"}"#);
let created: serde_json::Value = serde_json::from_str(&send(&mut c, r#"{"method":"role_create","user":"Reader"}"#))?;
assert_eq!(created["status"], "accounts", "{created}");
assert_eq!(created["accounts"][1]["can_login"], false);
assert_eq!(created["accounts"][1]["enabled"], true);
for change in [serde_json::json!({"enabled":false}), serde_json::json!({"can_login":false}), serde_json::json!({"is_superuser":false})] {
let mut request = change;
request["method"] = "identity_alter".into();
request["user"] = "root".into();
let denied = send(&mut c, &request.to_string());
assert!(denied.contains("last enabled login superuser"), "{denied}");
}
let collision = send(&mut c, r#"{"method":"account_create","user":"READER","password_hash":"hash"}"#);
assert!(collision.contains("already exists"), "{collision}");
let changed: serde_json::Value = serde_json::from_str(&send(&mut c, r#"{"method":"identity_alter","user":"reader","can_login":true,"is_superuser":true}"#))?;
assert_eq!(changed["accounts"][1]["revision"], 2);
drop(c);
drop(server);
let _restarted = restart_at(data.path())?;
let mut c = connect()?;
let restored: serde_json::Value = serde_json::from_str(&send(&mut c, r#"{"method":"accounts_list"}"#))?;
assert_eq!(restored, changed);
let denied = send(&mut c, r#"{"method":"scan","database":"system","table":"__system_identities"}"#);
assert!(denied.contains("reserved system table"), "{denied}");
Ok(())
}
/// 旧角色迁移与重名拒绝不破坏原数据
#[test]
fn identity_migration_preserves_roles_and_rejects_collisions() -> Result<(), Box<dyn std::error::Error>> {
let (_server, _data) = start_server();
let mut c = connect()?;
send(&mut c, r#"{"method":"bootstrap_system","user":"root","password_hash":"hash"}"#);
request_ok(&mut c, serde_json::json!({"method":"create_table","database":"system","table":"__system_roles","columns":[{"name":"name","ty":"str"}]}))?;
request_ok(&mut c, serde_json::json!({"method":"insert","database":"system","table":"__system_roles","row":{"name":"root"}}))?;
let before = send(&mut c, r#"{"method":"accounts_list"}"#);
let rejected = send(&mut c, r#"{"method":"identity_initialize","administrator":"root"}"#);
assert!(rejected.contains("identity name collision: root"), "{rejected}");
assert_eq!(send(&mut c, r#"{"method":"accounts_list"}"#), before);
request_ok(&mut c, serde_json::json!({"method":"replace_all","database":"system","table":"__system_roles","rows":[{"name":"reader"}]}))?;
let migrated: serde_json::Value = serde_json::from_str(&send(&mut c, r#"{"method":"identity_initialize","administrator":"root"}"#))?;
assert_eq!(migrated["accounts"][1]["user"], "reader");
assert_eq!(migrated["accounts"][1]["can_login"], false);
assert_eq!(serde_json::from_str::<serde_json::Value>(&send(&mut c, r#"{"method":"identity_initialize","administrator":"reader"}"#))?, migrated);
Ok(())
}
/// 删除身份清理授权并在恢复后保留编号上界
#[test]
fn identity_drop_cleanup_replays_without_reusing_ids() -> Result<(), Box<dyn std::error::Error>> {
let (server, data) = start_server();
let mut c = connect()?;
send(&mut c, r#"{"method":"bootstrap_system","user":"root","password_hash":"hash"}"#);
send(&mut c, r#"{"method":"role_create","user":"reader"}"#);
for table in ["__system_grants", "__system_grant_options", "__system_members"] {
request_ok(&mut c, serde_json::json!({"method":"create_table","database":"system","table":table,"columns":[{"name":"role","ty":"str"},{"name":"member","ty":"str"}]}))?;
request_ok(&mut c, serde_json::json!({"method":"insert","database":"system","table":table,"row":{"role":"reader","member":"root"}}))?;
}
std::fs::create_dir(data.path().join("system/catalog.json.pending"))?;
let failed = send(&mut c, r#"{"method":"account_drop","user":"reader"}"#);
assert!(failed.contains("error"), "{failed}");
drop(c);
drop(server);
std::fs::remove_dir(data.path().join("system/catalog.json.pending"))?;
let _restarted = restart_at(data.path())?;
let mut c = connect()?;
for table in ["__system_grants", "__system_grant_options", "__system_members"] {
let rows: serde_json::Value = serde_json::from_str(&send(&mut c, &serde_json::json!({"method":"scan","database":"system","table":table}).to_string()))?;
assert_eq!(rows["rows"], serde_json::json!([]));
}
let recreated: serde_json::Value = serde_json::from_str(&send(&mut c, r#"{"method":"role_create","user":"reader"}"#))?;
assert_eq!(recreated["accounts"][1]["id"], 3);
Ok(())
}
/// 命名类型引用与删除依赖在写入时校验
#[test]
fn domain_dependencies_are_checked_before_wal() -> Result<(), Box<dyn std::error::Error>> {
let (_srv, _data) = start_server();
let mut connection = connect()?;
let columns = serde_json::json!([{"name":"code","ty":"domain(code,int)"}]);
let missing = serde_json::json!({"method":"create_table","table":"typed","columns":columns});
let response: serde_json::Value = serde_json::from_str(&send(&mut connection, &missing.to_string()))?;
assert_eq!(response["status"], "error");
request_ok(&mut connection, serde_json::json!({"method":"create_table","table":"__system_domain_code","columns":[{"name":"base","ty":"int"}]}))?;
let forged = serde_json::json!({"method":"create_table","table":"forged","columns":[{"name":"code","ty":"domain(code,str)"}]});
let response: serde_json::Value = serde_json::from_str(&send(&mut connection, &forged.to_string()))?;
assert_eq!(response["status"], "error");
request_ok(&mut connection, missing)?;
let response: serde_json::Value = serde_json::from_str(&send(&mut connection, r#"{"method":"drop_table","table":"__system_domain_code"}"#))?;
assert_eq!(response["status"], "error");
request_ok(&mut connection, serde_json::json!({"method":"drop_table","table":"typed"}))?;
request_ok(&mut connection, serde_json::json!({"method":"drop_table","table":"__system_domain_code"}))?;
let response: serde_json::Value = serde_json::from_str(&send(&mut connection, r#"{"method":"create_table","table":"stale","columns":[{"name":"code","ty":"domain(code,int)"}]}"#))?;
assert_eq!(response["status"], "error");
Ok(())
}
/// 嵌套类型依赖与重启持久化
#[test]
fn nested_domains_preserve_dependencies_across_restart() -> Result<(), Box<dyn std::error::Error>> {
let (_srv, data) = start_server();
let mut connection = connect()?;
request_ok(&mut connection, serde_json::json!({"method":"create_table","table":"__system_domain_root","columns":[{"name":"base","ty":"int"}]}))?;
request_ok(&mut connection, serde_json::json!({"method":"create_table","table":"__system_domain_child","columns":[{"name":"base","ty":"domain(root,int)"}]}))?;
let rejected: serde_json::Value = serde_json::from_str(&send(&mut connection, r#"{"method":"drop_table","table":"__system_domain_root"}"#))?;
assert_eq!(rejected["status"], "error");
request_ok(&mut connection, serde_json::json!({"method":"create_table","table":"typed","columns":[{"name":"id","ty":"domain(child,domain(root,int))"}]}))?;
let forged: serde_json::Value = serde_json::from_str(&send(&mut connection, r#"{"method":"create_table","table":"forged","columns":[{"name":"id","ty":"domain(child,domain(root,str))"}]}"#))?;
assert_eq!(forged["status"], "error");
drop(connection);
CURRENT.with(|c| *c.borrow_mut() = None);
drop(_srv);
let _reopened = start_server_in(data.path());
let mut connection = connect()?;
let schema: serde_json::Value = serde_json::from_str(&send(&mut connection, r#"{"method":"describe_table","table":"typed"}"#))?;
assert_eq!(schema["status"], "schema");
assert_eq!(schema["columns"][0]["ty"], "domain(child,domain(root,int))");
request_ok(&mut connection, serde_json::json!({"method":"drop_table","table":"typed"}))?;
request_ok(&mut connection, serde_json::json!({"method":"drop_table","table":"__system_domain_child"}))?;
request_ok(&mut connection, serde_json::json!({"method":"drop_table","table":"__system_domain_root"}))?;
Ok(())
}
/// 重置与结构修复保留业务文件及系统数据
#[test]
fn offline_maintenance_preserves_data_and_rejects_live_storage() -> Result<(), Box<dyn std::error::Error>> {
let data = tempfile::tempdir()?;
let config = write_config(data.path());
let business = data.path().join("databases/business");
std::fs::create_dir_all(&business)?;
std::fs::write(business.join("sentinel"), b"business data")?;
let types = type_seed_request()["types"].clone();
let request = serde_json::json!({"mode":"reset","user":"root","password_hash":"original-hash","types":types});
Storage::maintenance(config.to_str(), &request.to_string())?;
let system = data.path().join("system");
let credentials = std::fs::read(system.join("__system_users.db"))?;
let mut catalog: serde_json::Value = serde_json::from_slice(&std::fs::read(system.join("catalog.json"))?)?;
catalog["tables"]["__system_identities"]["columns"] = serde_json::json!([]);
std::fs::write(system.join("catalog.json"), serde_json::to_vec(&catalog)?)?;
let repair = serde_json::json!({"mode":"repair","types":types});
Storage::maintenance(config.to_str(), &repair.to_string())?;
assert_eq!(std::fs::read(system.join("__system_users.db"))?, credentials);
assert_eq!(std::fs::read(business.join("sentinel"))?, b"business data");
let opened = Storage::open(config.to_str())?;
assert!(Storage::maintenance(config.to_str(), &request.to_string()).unwrap_err().contains("data directory is in use"));
drop(opened);
std::fs::remove_file(system.join("__system_users.db"))?;
let before = std::fs::read(system.join("catalog.json"))?;
let failure = Storage::maintenance(config.to_str(), &repair.to_string()).unwrap_err();
assert!(failure.contains("repair would require"));
assert_eq!(std::fs::read(system.join("catalog.json"))?, before);
assert!(!system.join("__system_users.db").exists());
Ok(())
}
/// 从账号 WAL 恢复缺失凭据文件
#[test]
fn offline_recovery_replays_account_wal() -> Result<(), Box<dyn std::error::Error>> {
let data = tempfile::tempdir()?;
let config = write_config(data.path());
let types = type_seed_request()["types"].clone();
Storage::maintenance(config.to_str(), &serde_json::json!({"mode":"reset","user":"root","password_hash":"original-hash","types":types}).to_string())?;
let storage = Storage::open(config.to_str())?;
let accounts: serde_json::Value = serde_json::from_str(&storage.request_line(r#"{"method":"accounts_list"}"#))?;
let saved = serde_json::from_value(accounts["accounts"].clone())?;
drop(storage);
let system = data.path().join("system");
let wal = chusql_core_storage::wal::Wal::new(system.join("wal.log"));
wal.append(&chusql_core_storage::wal::WalOp::Accounts { accounts: saved, removed: Vec::new(), migrate_roles: false })?;
wal.append(&chusql_core_storage::wal::WalOp::Commit)?;
drop(wal);
std::fs::remove_file(system.join("__system_users.db"))?;
Storage::maintenance(config.to_str(), &serde_json::json!({"mode":"recover","types":types}).to_string())?;
let storage = Storage::open(config.to_str())?;
let restored: serde_json::Value = serde_json::from_str(&storage.request_line(r#"{"method":"accounts_list"}"#))?;
assert_eq!(restored["accounts"], accounts["accounts"]);
drop(storage);
std::fs::write(system.join("catalog.json"), b"corrupted catalog")?;
Storage::maintenance(config.to_str(), &serde_json::json!({"mode":"recover","types":types}).to_string())?;
let storage = Storage::open(config.to_str())?;
let reconstructed: serde_json::Value = serde_json::from_str(&storage.request_line(r#"{"method":"accounts_list"}"#))?;
assert_eq!(reconstructed["accounts"], accounts["accounts"]);
Ok(())
}
/// 一次会话就是当前线程的存储句柄
struct Conn(Arc<Storage>);
@@ -800,7 +1227,7 @@ fn account_snapshot_replays_twice_without_duplicating_accounts() -> Result<(), B
for _ in 0..2 {
let wal = Wal::new(data.path().join("system/wal.log"));
wal.truncate()?;
wal.append(&WalOp::Accounts { accounts: accounts.clone() })?;
wal.append(&WalOp::Accounts { accounts: accounts.clone(), removed: Vec::new(), migrate_roles: false })?;
drop(wal);
std::fs::write(data.path().join("system/__system_users.idx"), b"torn index")?;
std::fs::write(data.path().join("system/__system_users.db"), b"torn heap")?;
@@ -1595,7 +2022,7 @@ fn stats_report_distinct_values() {
send(&mut c, r#"{"method":"delete_keys","table":"stat_t","keys":[1]}"#);
let r = send(&mut c, r#"{"method":"describe_table","table":"stat_t"}"#);
assert!(r.contains(r#""row_count":2"#), "after delete: {}", r);
assert!(r.contains(r#""name":"age","distinct":1"#), "after delete: {}", r);
assert!(r.contains(r#""name":"age","distinct":2"#), "remaining ages are still 30 and 41: {}", r);
}
/// 重开服务后索引仍在
@@ -1832,12 +2259,14 @@ fn account_table_uses_typed_columns_and_stamps_login() -> Result<(), Box<dyn std
assert!(account["last_login_at"].is_null(), "还没登录过: {}", account);
let catalog: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(data.path().join("system/catalog.json"))?)?;
let entry = &catalog["tables"]["__system_users"];
let credential_columns = &catalog["tables"]["__system_users"]["columns"];
assert_eq!(credential_columns.as_array().ok_or("missing credentials")?.iter().map(|c| c["name"].as_str().unwrap_or("")).collect::<Vec<_>>(), vec!["id", "password_hash"]);
let entry = &catalog["tables"]["__system_identities"];
let columns = entry["columns"].as_array().ok_or("missing columns")?;
let names: Vec<&str> = columns.iter().map(|col| col["name"].as_str().unwrap_or("")).collect();
assert_eq!(names, vec!["id", "user", "password_hash", "registered_at", "last_login_at", "revision"]);
assert_eq!(names, vec!["id", "user", "can_login", "is_superuser", "system_catalog_manager", "enabled", "identity_version", "registered_at", "last_login_at", "revision"]);
let types: Vec<&str> = columns.iter().map(|col| col["ty"].as_str().unwrap_or("")).collect();
assert_eq!(types, vec!["int", "varchar(64)", "varchar(256)", "timestamp", "timestamp", "int"]);
assert_eq!(types, vec!["int", "varchar(64)", "bool", "bool", "bool", "bool", "int", "timestamp", "timestamp", "int"]);
assert_eq!(columns[0]["primary_key"], true, "id 是主键");
assert_eq!(columns[0]["nullable"], false);
assert_eq!(columns[1]["unique"], true, "用户名唯一");
+25
View File
@@ -207,6 +207,31 @@ fn trailing_torn_frame_can_be_truncated() {
assert_eq!(wal.read_all().unwrap().records.len(), 1);
}
/// 完整损坏帧返回错误而非截断成功
#[test]
fn complete_corrupt_frame_is_rejected() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("wal.log");
std::fs::write(&path, [1, 0, 0, 0, 255]).unwrap();
assert!(Wal::new(&path).read_all().is_err());
assert_eq!(std::fs::read(path).unwrap(), [1, 0, 0, 0, 255]);
}
/// 检查点损坏与替换失败不丢旧值
#[test]
fn checkpoint_corruption_and_write_failure_are_explicit() {
let dir = tempfile::tempdir().unwrap();
let wal = Wal::new(dir.path().join("wal.log"));
for bytes in [vec![], vec![1; 7], vec![1; 9]] {
std::fs::write(dir.path().join("wal.checkpoint"), bytes).unwrap();
assert!(wal.read_checkpoint().is_err());
}
wal.write_checkpoint(7).unwrap();
std::fs::create_dir(dir.path().join("wal.checkpoint.pending")).unwrap();
assert!(wal.write_checkpoint(9).is_err());
assert_eq!(wal.read_checkpoint().unwrap(), Some(7));
}
/// 检查点写读一致
#[test]
fn checkpoint_round_trips() {
+6 -5
View File
@@ -2,11 +2,12 @@ cabal-version: 2.4
name: chusql-interface
version: 1.0.0
synopsis: ChuSQL 接口层:配置、口令、语句构造与 TCP 客户端
description: 引擎无关的接口层。Web 管理端与命令行客户端都只依赖这层:
读同一份 chusql.toml、用同一个线上协议连到拥有存储库的
chusql-server,然后在客户端会话上跑 SQL、看数据字典与角色。
存储引擎与存储动态库一概不在这层。
synopsis: ChuSQL configuration, authentication, SQL helpers, and TCP client
description: Provides engine-independent interfaces shared by web and
command-line clients, including settings.toml configuration,
password handling, statement construction, and TCP sessions.
Supports SQL execution and catalog and role inspection through
chusql-server without linking the engine or storage library.
build-type: Simple
library
+18 -13
View File
@@ -24,7 +24,8 @@ import qualified Data.Text as T
import System.Directory (doesDirectoryExist)
import Text.Read (readMaybe)
-- Web 与 server 的运行参数:命令行 > chusql.toml 分区 > 内置默认。
-- Web 与 server 的运行参数:命令行 > settings.toml 分区 > 默认。
-- 监听地址用 listen_host;静态目录与管理员名写死,不认配置键。
-- | 默认账号名
defaultUser :: Text
@@ -37,6 +38,7 @@ data WebConfig = WebConfig
, wcStaticDir :: FilePath
, wcUser :: Text
, wcDataDir :: Maybe FilePath
, wcLogFiles :: FilePath
, wcCookieSecure :: Bool
, wcBodyLimit :: Int
, wcSessionIdle :: Int
@@ -60,6 +62,7 @@ defaultWebConfig =
, wcStaticDir = "static"
, wcUser = ""
, wcDataDir = Nothing
, wcLogFiles = "./logs"
, wcCookieSecure = False
, wcBodyLimit = 65536
, wcSessionIdle = 8 * 3600
@@ -81,11 +84,10 @@ canonicalSettingKeys saved =
-- | 取一个键的规范名
canonicalKey key = fromMaybe key (lookup key keyAliases)
-- | 下划线写法到连字符写法的别名
-- | 设置键别名:非权威写法到权威写法(多数写连字符)
keyAliases :: [(Text, Text)]
keyAliases =
[ ("static_dir", "static-dir")
, ("session_idle", "session-idle")
[ ("session_idle", "session-idle")
, ("session_max", "session-max")
, ("login_max_attempts", "login-max-attempts")
, ("login_window", "login-window")
@@ -96,6 +98,7 @@ keyAliases =
, ("max_sql_length", "max-sql-length")
, ("password_min_length", "password-min-length")
, ("password_classes", "password-classes")
, ("listen-host", "listen_host")
]
-- | 配置文件的 [web] 分节里是否还留着明文口令键
@@ -108,11 +111,11 @@ nonEmptyValue key saved = case T.strip <$> Map.lookup key saved of
Just value | not (T.null value) -> Just value
_ -> Nothing
-- | 管理员名字:命令行 > 设置文件 > 内置默认
rootUserName :: WebConfig -> Map.Map Text Text -> Text
rootUserName cfg saved
-- | 管理员名字:命令行 > 内置默认(超级用户由内部自举)
rootUserName :: WebConfig -> Text
rootUserName cfg
| not (T.null (T.strip (wcUser cfg))) = T.strip (wcUser cfg)
| otherwise = fromMaybe defaultUser (nonEmptyValue "user" saved)
| otherwise = defaultUser
-- | 读配置:默认叠上 [web] 与 [storage]
loadWebConfigAt :: FilePath -> IO WebConfig
@@ -121,18 +124,20 @@ loadWebConfigAt path = do
storage <- readSection path "storage"
pure (withStorage storage (webConfigFromSection (canonicalSettingKeys raw)))
-- | 数据目录取自 [storage] 段
-- | 数据与日志目录取自 [storage] 段
withStorage :: Map.Map Text Text -> WebConfig -> WebConfig
withStorage storage cfg =
cfg{wcDataDir = T.unpack <$> nonEmptyValue "data_dir" storage}
cfg
{ wcDataDir = T.unpack <$> nonEmptyValue "data_dir" storage
, wcLogFiles = maybe (wcLogFiles cfg) T.unpack (nonEmptyValue "log_files" storage)
}
-- | [web] 分区里的启动参数,缺失或坏值一律回到内置默认
webConfigFromSection :: Map.Map Text Text -> WebConfig
webConfigFromSection saved =
defaultWebConfig
{ wcHost = T.unpack (sectionText "host" saved (T.pack (wcHost defaultWebConfig)))
{ wcHost = T.unpack (sectionText "listen_host" saved (T.pack (wcHost defaultWebConfig)))
, wcPort = sectionInt "port" saved (wcPort defaultWebConfig)
, wcStaticDir = T.unpack (sectionText "static-dir" saved (T.pack (wcStaticDir defaultWebConfig)))
, wcCookieSecure = sectionBool "cookie-secure" saved (wcCookieSecure defaultWebConfig)
, wcBodyLimit = sectionInt "body-limit" saved (wcBodyLimit defaultWebConfig)
, wcSessionIdle = sectionInt "session-idle" saved (wcSessionIdle defaultWebConfig)
@@ -202,7 +207,7 @@ loadServerConfigAt path = do
Nothing -> fallback
pure
defaultServerConfig
{ scHost = textOf "host" (scHost defaultServerConfig)
{ scHost = textOf "listen_host" (scHost defaultServerConfig)
, scPort = intOf "port" (scPort defaultServerConfig)
, scMaxMessage = intOf "max_message" (scMaxMessage defaultServerConfig)
, scMaxRows = intOf "max_rows" (scMaxRows defaultServerConfig)
@@ -15,14 +15,14 @@ module ChuSQL.Interface.Settings (
effectiveSettings,
) where
import ChuSQL.Interface.Config (WebConfig (..), canonicalSettingKeys, defaultUser)
import ChuSQL.Interface.Config (WebConfig (..), canonicalSettingKeys)
import ChuSQL.Interface.TOML (readSection, writeSection)
import Data.List (nub)
import qualified Data.Map.Strict as Map
import Data.Text (Text)
import qualified Data.Text as T
-- 设置目录,以及全局 chusql.toml 的读、写与校验。
-- 设置目录,以及全局 settings.toml 的读、写与校验。
-- | 一个可配置项
data SettingItem = SettingItem
@@ -42,8 +42,7 @@ data SettingItem = SettingItem
settingCatalogue :: [SettingItem]
settingCatalogue =
[ item "port" "HTTP port" "server" "int" "7778" True True
, item "host" "Listen address" "server" "text" "127.0.0.1" True True
, item "static-dir" "Static directory" "server" "text" "static" True True
, withTomlKey "listen_host" (item "listen-host" "Listen address" "server" "text" "127.0.0.1" True True)
, item "cookie-secure" "Secure cookie (behind TLS)" "server" "bool" "0" True False
, item "body-limit" "Max request body (bytes)" "limits" "int" "65536" False False
, item "session-idle" "Session idle timeout (s)" "limits" "int" "28800" False False
@@ -54,16 +53,16 @@ settingCatalogue =
, item "max-page-size" "Rows per page ceiling" "limits" "int" "500" False False
, item "max-rows" "Rows per query" "limits" "int" "1000" False False
, item "max-sql-length" "Max SQL characters" "limits" "int" "20000" False False
, item "user" "管理员账号(设置文件)" "auth" "text" "root" True True
, item "password-min-length" "口令最短长度" "auth" "int" "12" True False
, item "password-classes" "口令字符类别数" "auth" "int" "2" True False
, owned "data-dir" "Data directory" "storage" "text" "" "storage" "data_dir"
, owned "log-files" "Log directory" "storage" "text" "./logs" "storage" "log_files"
, owned "storage-page-size" "Storage page size" "storage" "int" "4096" "page" "size"
, owned "storage-btree-order" "Storage B+tree order" "storage" "int" "4" "btree" "order"
, owned "storage-buffer-pool" "Storage buffer pool pages" "storage" "int" "1024" "buffer" "pool_size"
, owned "storage-log" "Storage log level" "storage" "text" "info" "log" "level"
, item "seed" "Seed demo data on start" "storage" "bool" "1" True True
, owned "tcp-host" "TCP listen address" "network" "text" "127.0.0.1" "server" "host"
, owned "tcp-listen-host" "TCP listen address" "network" "text" "127.0.0.1" "server" "listen_host"
, owned "tcp-port" "TCP port" "network" "int" "7777" "server" "port"
, owned "tcp-max-message" "TCP max request line (bytes)" "network" "int" "1048576" "server" "max_message"
, owned "tcp-max-rows" "TCP rows per result" "network" "int" "1000" "server" "max_rows"
@@ -96,6 +95,9 @@ settingCatalogue =
, siTomlKey = tomlKey
}
-- | 换掉落盘用的键名:界面键与 TOML 键不同名时用
withTomlKey key spec = spec{siTomlKey = key}
-- | web 自己的分区名
webSection :: Text
webSection = "web"
@@ -114,9 +116,9 @@ findItem key = case [i | i <- settingCatalogue, siKey i == key] of
isRootOnly :: Text -> Bool
isRootOnly key = maybe False siRootOnly (findItem key)
-- | 网页端不能改的键
-- | 网页端不能改的键(当前没有)
lockedKeys :: [Text]
lockedKeys = ["user"]
lockedKeys = []
-- | 这项在界面上是不是只读
isLockedSetting :: Text -> Bool
@@ -218,8 +220,7 @@ effectiveSettings :: WebConfig -> Map.Map Text Text
effectiveSettings cfg =
Map.fromList
[ ("port", tshow (wcPort cfg))
, ("host", T.pack (wcHost cfg))
, ("static-dir", T.pack (wcStaticDir cfg))
, ("listen-host", T.pack (wcHost cfg))
, ("cookie-secure", boolText (wcCookieSecure cfg))
, ("body-limit", tshow (wcBodyLimit cfg))
, ("session-idle", tshow (wcSessionIdle cfg))
@@ -230,9 +231,9 @@ effectiveSettings cfg =
, ("max-page-size", tshow (wcMaxPageSize cfg))
, ("max-rows", tshow (wcMaxRows cfg))
, ("max-sql-length", tshow (wcMaxSqlLength cfg))
, ("user", if T.null (T.strip (wcUser cfg)) then defaultUser else T.strip (wcUser cfg))
, ("seed", boolText (wcSeedDemo cfg))
, ("data-dir", maybe "" T.pack (wcDataDir cfg))
, ("log-files", T.pack (wcLogFiles cfg))
]
where
-- | 数值转文本
@@ -18,7 +18,7 @@ import qualified Data.Text.Encoding as TE
import System.Directory (XdgDirectory (XdgConfig), createDirectoryIfMissing, doesFileExist, getXdgDirectory)
import System.FilePath (takeDirectory, (</>))
-- 全局 chusql.toml 的最小读写:只认 [section] 与 key = value。
-- 全局 settings.toml 的最小读写:只认 [section] 与 key = value。
-- | 读一个分区;文件不存在或没有这个分区都当空表
readSection :: FilePath -> Text -> IO (Map Text Text)
@@ -165,7 +165,7 @@ appendSection section entries lines' =
defaultConfigFile :: IO FilePath
defaultConfigFile = do
dir <- getXdgDirectory XdgConfig "ChuSQL"
pure (dir </> "chusql.toml")
pure (dir </> "settings.toml")
-- | 定位配置文件:命令行 --config 优先,否则用固定路径
resolveConfigPath :: Maybe FilePath -> IO FilePath
+16 -14
View File
@@ -12,7 +12,6 @@ import ChuSQL.Interface.Config (
rootUserName,
)
import ChuSQL.Interface.RateLimit (newRateLimiter)
import ChuSQL.Interface.Settings (readSettingsFile)
import ChuSQL.Interface.TOML (resolveConfigPath)
import ChuSQL.Server.Accounts (administratorPasswordless)
import ChuSQL.Server.Backend (Backend (..), ipcBackend)
@@ -69,7 +68,7 @@ parseArgs = go emptyOptions
go opts ("--help" : rest) = go opts{optHelp = True} rest
go opts ("-h" : rest) = go opts{optHelp = True} rest
go opts ("--config" : v : rest) = go opts{optConfig = Just v} rest
go opts ("--host" : v : rest) = go opts{optHost = Just v} rest
go opts ("--listen-host" : v : rest) = go opts{optHost = Just v} rest
go opts ("--user" : v : rest) = go opts{optUser = Just (T.pack v)} rest
go opts ("--port" : v : rest) = withInt "--port" v (\n -> go opts{optPort = Just n} rest)
go opts ("--max-message" : v : rest) = withInt "--max-message" v (\n -> go opts{optMaxMessage = Just n} rest)
@@ -85,16 +84,16 @@ usage :: IO ()
usage = do
putStrLn "usage: chusql-server [options]"
putStrLn ""
putStrLn "config: chusql.toml sits at a fixed place and holds every knob;"
putStrLn " Windows: %APPDATA%\\ChuSQL\\chusql.toml"
putStrLn " other: $XDG_CONFIG_HOME/ChuSQL/chusql.toml (or ~/.config/ChuSQL/chusql.toml)"
putStrLn " [storage] data_dir; [server] host/port/max_message/max_rows;"
putStrLn " [web] user (administrator name; the password lives in the system table)"
putStrLn "config: settings.toml sits at a fixed place and holds every knob;"
putStrLn " Windows: %APPDATA%\\ChuSQL\\settings.toml"
putStrLn " other: $XDG_CONFIG_HOME/ChuSQL/settings.toml (or ~/.config/ChuSQL/settings.toml)"
putStrLn " [storage] data_dir/log_files; [server] listen_host/port/max_message/max_rows;"
putStrLn " [web] listen_host/port and the limits; the administrator name only comes from --user"
putStrLn ""
putStrLn " --config FILE read that config file instead"
putStrLn " --host H listen address ([server] host, default 127.0.0.1)"
putStrLn " --listen-host H listen address ([server] listen_host, default 127.0.0.1)"
putStrLn " --port N listen port ([server] port, default 7777)"
putStrLn " --user NAME administrator name ([web] user, default root)"
putStrLn (" --user NAME administrator name (default " ++ T.unpack defaultUser ++ ")")
putStrLn " --max-message N max bytes per request line ([server] max_message, default 1048576)"
putStrLn " --max-rows N rows returned per query ([server] max_rows, default 1000)"
putStrLn ""
@@ -104,8 +103,8 @@ usage = do
putStrLn ""
putStrLn "storage: this process loads the chusql_core_storage library in-process and owns the data directory"
putStrLn ""
putStrLn "administrator password: lives in the __system_users system table, never in chusql.toml"
putStrLn (" the [web] user key only picks the administrator name (default " ++ T.unpack defaultUser ++ ")")
putStrLn "administrator password: lives in the __system_users system table, never in settings.toml"
putStrLn " the administrator name comes from --user; settings.toml does not carry it"
putStrLn ""
putStrLn "first run: the installer calls csql-bootstrap, which creates the system catalog"
putStrLn " {\"method\":\"system_status\"} tells whether that happened; without it the server stops"
@@ -154,14 +153,15 @@ run opts = do
configPath <- resolveConfigPath (optConfig opts)
base <- loadWebConfigAt configPath
let cfg = applyWebOptions base opts
saved <- readSettingsFile configPath
legacy <- legacyPasswordKey configPath
let rootName = rootUserName cfg saved
let rootName = rootUserName cfg
config <- applyServerOptions <$> loadServerConfigAt configPath <*> pure opts
opened <- localStorageLink (Just configPath)
case opened of
Left err -> do
putStrLn ("cannot open the storage library: " ++ err)
putStrLn "Stop all database processes, then try: csql-bootstrap repair --config <settings.toml>"
putStrLn "If data recovery is required, use csql-bootstrap recover; reset discards system identities and grants."
exitFailure
Right link -> do
setStorageLink link
@@ -199,7 +199,8 @@ checkInitialized backend = do
| statusInitialized value -> putStrLn "system: ready (the catalog is initialized)"
_ -> do
putStrLn "!! the system catalog is not initialized; run the bootstrap program first:"
putStrLn "!! csql-bootstrap --config <chusql.toml> --password-stdin"
putStrLn "!! csql-bootstrap --config <settings.toml> --password-stdin"
putStrLn "!! For a damaged existing system catalog, stop all processes and try csql-bootstrap repair or recover."
exitFailure
where
-- | 应答里的 initialized 是不是 true
@@ -247,3 +248,4 @@ printBanner cfg config configPath = do
)
putStrLn ("storage: chusql_core_storage library " ++ version)
putStrLn ("data dir: " ++ orElse (wcDataDir cfg) "(from the config file, else the default)")
putStrLn ("log files: " ++ wcLogFiles cfg)
+5 -5
View File
@@ -2,11 +2,11 @@ cabal-version: 2.4
name: chusql-server
version: 1.0.0
synopsis: ChuSQL 数据库服务器:TCP 端点 + 自家协议 + SQL 会话
description: ChuSQL 的数据库服务器。负责监听 TCP、一行一条 JSON 的自家协议、
登录与限流,并把用户 SQL 转交给 chusql-core-engine(存储则由进程内
的 chusql_core_storage 动态库承担)。Web 管理端与命令行客户端都只
通过这条 TCP 连接访问数据库。
synopsis: ChuSQL database server with TCP endpoints and SQL sessions
description: Provides the ChuSQL TCP server and newline-delimited JSON protocol,
authentication, rate limiting, and SQL sessions. SQL is executed by
chusql-core-engine with the in-process chusql_core_storage library.
Web and command-line clients access the database through TCP.
build-type: Simple
library
+79 -43
View File
@@ -3,7 +3,7 @@
module ChuSQL.Server.Accounts (
Accounts, AccountError (..), Principal (..), PasswordPolicy (..), defaultPasswordPolicy,
newAccounts, accountsPolicy, setAccountsPolicy, passwordAllowed,
ensureRootAccount, administratorPasswordless, rootUserName, principalName, principalIsRoot,
ensureRootAccount, administratorPasswordless, rootUserName, principalName, principalIsRoot, principalIsCatalogManager,
authenticate, currentPrincipal,
listAccounts, findAccount, AccountCommand (..), accountCommand, runAccountCommand,
) where
@@ -17,14 +17,15 @@ import ChuSQL.Server.Backend (Backend (..), currentStamp)
import Control.Concurrent.MVar (MVar, newMVar, withMVar)
import Data.Char (isAlphaNum, isAscii, isAsciiLower, isAsciiUpper, isDigit)
import Data.IORef (IORef, newIORef, readIORef, writeIORef)
import Data.List (nub)
import Data.Text (Text)
import qualified Data.Text as T
-- 账号服务:管理员是系统表里名字固定的那一行,口令哈希存在表里,普通账号由管理员用 CREATE/ALTER/DROP USER 管理。
-- 身份服务:按登录、最高权限和启用属性认证,管理身份与口令。
data AccountError = AccountError Text Text deriving (Show, Eq)
-- | 当前身份:名字固定的管理员,或系统表里的一个普通账号
-- | 当前认证身份与最高权限标志
data Principal
= Root Text
| Ordinary Account
@@ -40,10 +41,16 @@ principalName :: Principal -> Text
principalName (Root name) = name
principalName (Ordinary a) = accountUser a
-- | 是不是名字固定的管理员
-- | 判断身份是否具备最高权限
principalIsRoot :: Principal -> Bool
principalIsRoot (Root _) = True
principalIsRoot (Ordinary _) = False
principalIsRoot (Ordinary a) = accountIsSuperuser a && accountEnabled a && accountCanLogin a
-- | 判断身份是否可管理系统目录
principalIsCatalogManager :: Principal -> Bool
principalIsCatalogManager principal = principalIsRoot principal || case principal of
Ordinary a -> accountSystemCatalogManager a && accountEnabled a && accountCanLogin a
Root _ -> True
data Accounts = Accounts
{ acBackend :: Backend
@@ -110,13 +117,9 @@ validateUserName name
-- | 该字符是否允许使用
allowed c = isAscii c && (isAlphaNum c || c `elem` ("_.-" :: String))
-- | 校验新账号名,并挡掉管理员保留名
-- | 校验新身份的名称
validateNewName :: Accounts -> Text -> Either AccountError ()
validateNewName service name = do
validateUserName name
if normalize name == rootName service
then Left (AccountError "bad_request" "the administrator name is reserved")
else Right ()
validateNewName _ name = validateUserName name
storageFailure :: AccountError
storageFailure = AccountError "storage_error" "account storage unavailable"
@@ -141,24 +144,25 @@ readAccounts backend = fmap (either (const (Left storageFailure)) Right) (beAcco
-- | 表里没有管理员那一行就补一行空哈希,表示还没设口令
ensureRootAccount :: Backend -> Text -> IO (Either AccountError ())
ensureRootAccount backend name = do
stored <- readAccounts backend
initialized <- beAccounts backend (ReqIdentityInitialize (normalize name))
let stored = either (const (Left storageFailure)) Right initialized
case stored of
Left err -> pure (Left err)
Right accounts
| any ((== normalize name) . accountUser) accounts -> pure (Right ())
| otherwise -> do
created <- beAccounts backend (ReqAccountCreate (normalize name) "")
pure $ case created of
Right _ -> Right ()
Left "account already exists" -> Right ()
Left _ -> Left storageFailure
case created of
Right _ -> storageResult <$> beAccounts backend (ReqIdentityAlter (normalize name) Nothing (Just True) Nothing Nothing)
Left "account already exists" -> pure (Right ())
Left _ -> pure (Left storageFailure)
-- | 管理员是不是还没设口令
administratorPasswordless :: Backend -> Text -> IO Bool
administratorPasswordless backend name = do
stored <- readAccounts backend
pure $ case stored of
Right accounts -> any (\a -> accountUser a == normalize name && T.null (accountHash a)) accounts
Right accounts -> any (\a -> accountUser a == normalize name && accountIsSuperuser a && accountEnabled a && accountCanLogin a && T.null (accountHash a)) accounts
Left _ -> False
-- | 按令牌解析当前身份,版本对不上即失效
@@ -174,8 +178,8 @@ currentUnlocked service token = do
account <- case filter ((== user) . accountUser) accounts of
[a] -> Right a
_ -> Left unauthorized
if revision == Just (accountRevision account)
then Right (if user == rootName service then Root user else Ordinary account)
if revision == Just (accountRevision account) && accountEnabled account && accountCanLogin account
then Right (if accountIsSuperuser account then Root user else Ordinary account)
else Left unauthorized
-- | 加锁解析当前身份
@@ -185,28 +189,27 @@ currentPrincipal service token = withMVar (acLock service) $ \_ -> currentUnlock
-- | 登录:比哈希,管理员未设口令时只收空口令
authenticate :: Accounts -> Text -> Text -> IO (Either AccountError Text)
authenticate service user password = withMVar (acLock service) $ \_ -> do
stored <- readAccounts (acBackend service)
initialized <- beAccounts (acBackend service) (ReqIdentityInitialize (rootName service))
let stored = either (const (Left storageFailure)) Right initialized
case stored of
Left err -> pure (Left err)
Right accounts
| name == rootName service -> signInRoot accounts
| any (\a -> accountUser a == name && accountIsSuperuser a && accountEnabled a && accountCanLogin a) accounts -> signInRoot accounts
| unset accounts -> pure (Left ordinaryRefused)
| otherwise -> signInOrdinary accounts
where
name = normalize user
ordinaryRefused = AccountError "forbidden" "this server only accepts the administrator"
-- | 管理员那一行还没设口令(或压根不在表里)
unset accounts = case filter ((== rootName service) . accountUser) accounts of
[a] -> T.null (accountHash a)
_ -> True
-- | 判断是否有已设置口令的登录管理员
unset accounts = not (any (\a -> accountIsSuperuser a && accountEnabled a && accountCanLogin a && not (T.null (accountHash a))) accounts)
-- | 管理员登录
signInRoot accounts = case filter ((== rootName service) . accountUser) accounts of
signInRoot accounts = case filter ((== name) . accountUser) accounts of
[a] | T.null (accountHash a) && T.null password -> issue a
[a] | not (T.null (accountHash a)) && verifyPassword (accountHash a) password -> issue a
[a] | accountCanLogin a && accountEnabled a && not (T.null (accountHash a)) && verifyPassword (accountHash a) password -> issue a
_ -> pure (Left unauthorized)
-- | 普通账号登录
signInOrdinary accounts = case filter ((== name) . accountUser) accounts of
[a] | not (T.null (accountHash a)) && verifyPassword (accountHash a) password -> issue a
[a] | accountCanLogin a && accountEnabled a && not (T.null (accountHash a)) && verifyPassword (accountHash a) password -> issue a
_ -> pure (Left unauthorized)
-- | 记一次登录再发会话
issue a = do
@@ -218,7 +221,7 @@ authenticate service user password = withMVar (acLock service) $ \_ -> do
-- | 系统表里的账号清单(含管理员)
listAccounts :: Accounts -> IO (Either AccountError [Account])
listAccounts service = withMVar (acLock service) $ \_ -> readAccounts (acBackend service)
listAccounts service = withMVar (acLock service) $ \_ -> fmap (fmap (filter (\a -> accountCanLogin a || not (T.null (accountHash a))))) (readAccounts (acBackend service))
-- | 按用户名找一个账号
findAccount :: Accounts -> Text -> IO (Either AccountError Account)
@@ -233,7 +236,7 @@ findAccount service name = withMVar (acLock service) $ \_ -> do
-- | 建一个普通账号
createUser :: Accounts -> Text -> Text -> IO (Either AccountError ())
createUser service name password = withMVar (acLock service) $ \_ -> do
createUser service name password = do
policy <- accountsPolicy service
case validateNewName service name >> passwordAllowed policy password of
Left err -> pure (Left err)
@@ -243,10 +246,14 @@ createUser service name password = withMVar (acLock service) $ \_ -> do
-- | 设账号口令;管理员不受口令策略约束,空口令即免密
resetPassword :: Accounts -> Text -> Text -> IO (Either AccountError ())
resetPassword service name password = withMVar (acLock service) $ \_ -> do
resetPassword service name password = do
policy <- accountsPolicy service
let admin = normalize name == rootName service
checked = if admin then validateUserName name else validateNewName service name >> passwordAllowed policy password
stored <- readAccounts (acBackend service)
case stored of
Left err -> pure (Left err)
Right identities -> do
let admin = any (\a -> accountUser a == normalize name && accountIsSuperuser a) identities
checked = if admin then validateUserName name else validateUserName name >> passwordAllowed policy password
case checked of
Left err -> pure (Left err)
Right () -> do
@@ -255,10 +262,8 @@ resetPassword service name password = withMVar (acLock service) $ \_ -> do
-- | 删一个普通账号
dropUser :: Accounts -> Text -> IO (Either AccountError ())
dropUser service name = withMVar (acLock service) $ \_ ->
if normalize name == rootName service
then pure (Left (AccountError "bad_request" "the administrator cannot be dropped"))
else case validateUserName name of
dropUser service name =
case validateUserName name of
Left err -> pure (Left err)
Right () -> storageResult <$> beAccounts (acBackend service) (ReqAccountDrop (normalize name))
@@ -267,6 +272,7 @@ data AccountCommand
= CreateAccount Text Text
| ResetAccountPassword Text Text
| DropAccount Text
| AlterAccountAttributes Text [(String, Bool)]
deriving (Show, Eq)
-- | 一条语句是不是账号管理语句
@@ -274,12 +280,42 @@ accountCommand :: Statement -> Maybe AccountCommand
accountCommand (CreateUser name password) = Just (CreateAccount (T.pack name) (T.pack password))
accountCommand (AlterUser name password) = Just (ResetAccountPassword (T.pack name) (T.pack password))
accountCommand (DropUser name) = Just (DropAccount (T.pack name))
accountCommand (AlterIdentity name attributes) = Just (AlterAccountAttributes (T.pack name) attributes)
accountCommand _ = Nothing
-- | 执行一条账号管理命令;普通账号一律拒绝
-- | 按目录管理权限执行账号命令
runAccountCommand :: Accounts -> Principal -> AccountCommand -> IO (Either AccountError ())
runAccountCommand _ (Ordinary _) _ = pure (Left (AccountError "forbidden" "administrator required"))
runAccountCommand service (Root _) command = case command of
CreateAccount name password -> createUser service name password
ResetAccountPassword name password -> resetPassword service name password
DropAccount name -> dropUser service name
runAccountCommand service principal command = withMVar (acLock service) $ \_ -> run
where
-- | 校验并执行身份管理权限
run
| not (principalIsCatalogManager principal) = pure (Left (AccountError "forbidden" "catalog manager required"))
| not (principalIsRoot principal) = do
identities <- readAccounts (acBackend service)
case identities of
Left err -> pure (Left err)
Right accounts
| any (\a -> accountUser a == normalize (target command) && (accountIsSuperuser a || accountSystemCatalogManager a)) accounts
|| privilegedChange command -> pure (Left (AccountError "forbidden" "superuser required to manage privileged identities"))
| otherwise -> execute
| otherwise = execute
-- | 获取身份管理的目标
target (CreateAccount name _) = name
target (ResetAccountPassword name _) = name
target (DropAccount name) = name
target (AlterAccountAttributes name _) = name
-- | 判断是否修改特权属性
privilegedChange (AlterAccountAttributes _ attributes) = any (\(key, _) -> key `elem` ["superuser", "system_catalog_manager"]) attributes
privilegedChange _ = False
-- | 执行已授权的身份命令
execute = executeWith (if principalIsRoot principal then service else service{acBackend = (acBackend service){beAccounts = \request -> beAccounts (acBackend service) (ReqCatalogManage request)}})
-- | 通过受限存储请求执行管理操作
executeWith managed = case command of
CreateAccount name password -> createUser managed name password
ResetAccountPassword name password -> resetPassword managed name password
DropAccount name -> dropUser managed name
AlterAccountAttributes name attributes
| length (nub (map fst attributes)) /= length attributes -> pure (Left (AccountError "bad_request" "duplicate identity attribute"))
| otherwise ->
storageResult <$> beAccounts (acBackend managed) (ReqIdentityAlter (normalize name)
(lookup "login" attributes) (lookup "superuser" attributes) (lookup "enabled" attributes) (lookup "system_catalog_manager" attributes))
+159 -25
View File
@@ -13,6 +13,7 @@ module ChuSQL.Server.Backend (
membersTable,
optionsTable,
rolesTable,
scopeDdl,
statementNeedsDatabase,
statementTables,
systemDatabaseName,
@@ -45,6 +46,7 @@ import Data.Text (Text)
import Data.Time (getCurrentTime)
import Data.Time.Format (defaultTimeLocale, formatTime)
import qualified Data.Aeson as A
import qualified Data.Aeson.KeyMap as KM
import qualified Data.ByteString.Lazy.Char8 as BL
import qualified Data.Text as T
@@ -75,6 +77,7 @@ columnsFromStatement :: Database -> Statement -> [String]
columnsFromStatement db stmt = case prepare db stmt of
Right (Select cols _ _ _ _ _) -> cols
Right (SelectExpr items _ _ _ _ _) -> map fst items
Right ShowDomains -> ["domain", "base_type"]
_ -> []
-- | 走本地存储链路的后端
@@ -85,7 +88,7 @@ ipcBackend = do
, beCatalog = do
response <- sendRequest (ReqInDatabase current ReqListCatalog)
pure $ case response of
RespCatalog infos -> Right infos
RespCatalog infos -> Right (filter (not . isDomainTable . tiTable) infos)
RespError err -> Left err
_ -> Left "unexpected catalog response"
, bePing = do
@@ -134,8 +137,9 @@ ipcBackend = do
-- 引擎侧的数据字典按当前库过滤之后键是裸表名,补前缀会让查表落空;
-- 库名由 database 字段随请求带走,存储层自己按它分发。
runIpc :: String -> String -> IO (Either String StatementResult)
runIpc current sql = case parseStatement sql of
runIpc current sql = case parseStatement sql >>= scopeDdl current of
Left e -> pure (Left e)
Right stmt | null current && statementNeedsDatabase stmt -> pure (Left "no database selected")
Right stmt -> do
let env = sessionEnv current
result <- runIPCStorageIn (runStatementM stmt) env
@@ -157,7 +161,7 @@ memoryBackend = memoryBackendWith False
memoryBackendWith :: Bool -> String -> MVar Database -> Backend
memoryBackendWith trusted name ref =
Backend
{ beStatement = \sql -> case parseStatement sql of
{ beStatement = \sql -> case parseStatement sql >>= scopeDdl (if trusted then systemDatabaseName else name) of
Left e -> pure (Left e)
-- 内存夹具也有"库"的概念:USE 只能切到它有的两个名字(自己 + system),
-- 与 beDatabases 口径一致;引擎的内存存储不实现库管理,所以拦在这里。
@@ -167,31 +171,47 @@ memoryBackendWith trusted name ref =
Right stmt -> modifyMVar ref $ \db -> case runStatement (scoped db) stmt of
Left e -> pure (db, Left e)
Right (db', rows)
| any (hidden . fst) db' -> pure (db, Left "reserved system table")
| otherwise -> pure (filter (hidden . fst) db ++ db', Right (StatementResult (colsOf (scoped db) stmt rows) rows))
| any (\(key, _) -> hidden key && not (domainKey key)) db' -> pure (db, Left "reserved system table")
| otherwise -> pure (mergeScoped db db', Right (StatementResult (colsOf (scoped db) stmt rows) rows))
, beCatalog = do
db <- readMVar ref
pure (Right (map tableInfoOf (filter (not . internalTable . fst) db)))
pure (Right (map tableInfoOf (filter (not . internalTable . fst) (scoped db))))
, bePing = pure True
, beAccounts = \req -> do
stamp <- currentStamp
modifyMVar ref $ \db -> case memoryAccounts db stamp req of
Left err -> pure (db, Left err)
Right accounts -> pure ((usersTable, Table usersTable [("account", TStr)]
[[("account", VStr (BL.unpack (A.encode a))) ] | a <- accounts] Nothing) : filter ((/= usersTable) . fst) db, Right accounts)
Right accounts -> case memoryAccounts db stamp ReqAccountsList of
Left err -> pure (db, Left err)
Right previous -> pure (case req of ReqAccountsList -> db; ReqCatalogManage ReqAccountsList -> db; _ -> storeMemoryIdentities db previous accounts, Right accounts)
, beWithDatabase = withDatabase
, beDatabases = pure (Right (nub [name, systemDatabaseName]))
, beStorage = \_ -> pure (Left "storage is not available in this backend")
, beSnapshot = Right . scoped <$> readMVar ref
, beApplyTransaction = \ops -> modifyMVar ref $ \db -> case applyTransactionMemory ops db of
, beApplyTransaction = \ops -> modifyMVar ref $ \db -> case applyTransactionMemory ops (scoped db) of
Left err -> pure (db, Left err)
Right db' -> pure (db', Right ())
Right db' -> pure (mergeScoped db db', Right ())
}
where
-- | 按视角过滤库里的表
scoped = if trusted then filter (not . blocked . fst) else visible
scoped db = [(lastTablePart key, table {tableName = lastTablePart key}) | (key, table) <- db, inScope key, domainKey key || not (hidden key)]
-- | 限定键只属于指定工作库
inScope key = case break (== '.') key of
(_, []) -> True
(database, _) -> T.toLower (T.pack database) == T.toLower (T.pack (if trusted then systemDatabaseName else name))
-- | 该视角下要隐藏的表
hidden = if trusted then blocked else internalTable
hidden = (if trusted then blocked else internalTable) . lastTablePart
-- | 判断裸名或限定名是否为类型目录
domainKey = isDomainTable . lastTablePart
-- | 回写当前库并保留其他库和内部目录
mergeScoped original changed =
filter (\(key, _) -> not (inScope key) || (hidden key && not (domainKey key))) original
++ [(storageKey original key, table) | (key, table) <- changed]
-- | 保留夹具中的原始数据库限定键
storageKey original key = case [saved | (saved, _) <- original, inScope saved, lastTablePart saved == key] of
saved : _ -> saved
[] | any (\(saved, _) -> inScope saved && '.' `elem` saved) original -> (if trusted then systemDatabaseName else name) ++ "." ++ key
[] -> key
-- | 取结果列名,空结果回落到解析
colsOf db stmt rows = case rows of
[] -> columnsFromStatement db stmt
@@ -256,10 +276,10 @@ statementTables :: Statement -> [Text]
statementTables stmt = nub (case stmt of
Select{} ->
tableRefsOf (selectFrom stmt)
++ concatMap exprTables (maybe [] (: []) (selectWhere stmt))
++ concatMap exprTables (maybe [] (: []) (selectWhere stmt) ++ map Col (selectCols stmt ++ selectGroupBy stmt ++ map fst (selectOrderBy stmt)))
SelectExpr{} ->
tableRefsOf (selectFrom stmt)
++ concatMap exprTables (maybe [] (: []) (selectWhere stmt) ++ map snd (selectItems stmt))
++ concatMap exprTables (maybe [] (: []) (selectWhere stmt) ++ map snd (selectItems stmt) ++ map Col (selectGroupBy stmt ++ map fst (selectOrderBy stmt)))
Insert table _ rows -> T.pack table : concatMap exprTables (concat rows)
Update table assigns cond -> T.pack table : concatMap exprTables (map snd assigns ++ maybe [] (: []) cond)
Delete table cond -> T.pack table : concatMap exprTables (maybe [] (: []) cond)
@@ -277,7 +297,52 @@ statementTables stmt = nub (case stmt of
-- | 语句需不需要一个当前库:出现的表里有没写库名的就必须要
statementNeedsDatabase :: Statement -> Bool
statementNeedsDatabase stmt = any (not . T.any (== '.')) (statementTables stmt)
statementNeedsDatabase stmt = case stmt of
CreateTable{} -> True
DropTable{} -> True
CreateIndex{} -> True
DropIndex{} -> True
DropColumn{} -> True
AddColumn{} -> True
RenameColumn{} -> True
AlterColumnType{} -> True
AlterColumnDefault{} -> True
AlterColumnNull{} -> True
CreateDomain{} -> True
DropDomain{} -> True
ShowDomains -> True
_ -> not (null (statementTables stmt))
-- | 将当前库限定的结构目标转为裸表名
scopeDdl :: String -> Statement -> Either String Statement
scopeDdl current statement = case statement of
CreateTable table columns -> (\key -> CreateTable key columns) <$> target table
DropTable table -> DropTable <$> target table
CreateIndex table column -> (\key -> CreateIndex key column) <$> target table
DropIndex table column -> (\key -> DropIndex key column) <$> target table
DropColumn table column -> (\key -> DropColumn key column) <$> target table
AddColumn table column -> (\key -> AddColumn key column) <$> target table
RenameColumn table old new -> (\key -> RenameColumn key old new) <$> target table
AlterColumnType table column ty -> (\key -> AlterColumnType key column ty) <$> target table
AlterColumnDefault table column value -> (\key -> AlterColumnDefault key column value) <$> target table
AlterColumnNull table column nullable -> (\key -> AlterColumnNull key column nullable) <$> target table
_ -> statement <$ mapM_ checkReadTarget (statementTables statement)
where
-- | 数据引用限定在当前工作库
checkReadTarget table
| null current = Left "no database selected"
| otherwise = case T.breakOn "." table of
(_, suffix) | T.null suffix -> Right ()
(database, _) | T.toLower database == T.toLower (T.pack current) -> Right ()
_ -> Left ("table is outside the current database: " ++ T.unpack table)
-- | 校验库限定名并取当前库中的表名
target table
| null current = Left "no database selected"
| otherwise = case break (== '.') table of
(_, []) -> Right table
(database, '.' : key)
| T.toLower (T.pack database) == T.toLower (T.pack current) -> Right key
_ -> Left ("DDL target is outside the current database: " ++ table)
-- | FROM 子句里的所有表
tableRefsOf :: FromClause -> [Text]
@@ -289,6 +354,7 @@ tableRefsOf (FromJoin _ left right cond) = tableRefsOf left ++ tableRefsOf right
-- | 表达式里出现的表(子查询是唯一的来路)
exprTables :: Expr -> [Text]
exprTables expr = case expr of
Col column | [database, table, _] <- T.splitOn "." (T.pack column) -> [database <> "." <> table]
ScalarSub sub -> statementTables (subqueryStatement sub)
ExistsSub sub _ -> statementTables (subqueryStatement sub)
InSub value sub _ -> exprTables value ++ statementTables (subqueryStatement sub)
@@ -395,10 +461,6 @@ internalTable name = reserved name || privilegeTable name
blocked :: String -> Bool
blocked name = reserved name && not (privilegeTable name)
-- | 去掉内部表
visible :: Database -> Database
visible = filter (not . internalTable . fst)
-- | 当前 UTC 时间,写法与引擎的 timestamp 一致
currentStamp :: IO T.Text
currentStamp = T.pack . formatTime defaultTimeLocale "%Y-%m-%d %H:%M:%S" <$> getCurrentTime
@@ -406,26 +468,98 @@ currentStamp = T.pack . formatTime defaultTimeLocale "%Y-%m-%d %H:%M:%S" <$> get
-- | 在内存账号表上执行一条账号请求
memoryAccounts :: Database -> T.Text -> Request -> Either String [Account]
memoryAccounts db stamp req = do
accounts <- case lookup usersTable db of
stored <- case lookup "__system_identities" db of
Just table -> mapM decodeCredential (tableRows table)
Nothing -> case lookup usersTable db of
Nothing -> Right []
Just table -> mapM decodeAccount (tableRows table)
let accounts = stored
highWater = maximum (0 : map accountId accounts ++ [toInteger n | row <- maybe [] tableRows (lookup "__system_identity_sequence" db), Just (VInt n) <- [lookup "id" row]])
legacyNames = [T.toLower (T.pack name) | row <- maybe [] tableRows (lookup rolesTable db), Just (VStr name) <- [lookup "name" row]]
case req of
ReqCatalogManage command -> do
let target = case command of
ReqAccountCreate name _ -> Just name
ReqAccountReset name _ -> Just name
ReqAccountDrop name -> Just name
ReqIdentityAlter name _ Nothing _ Nothing -> Just name
_ -> Nothing
allowed = case command of ReqAccountsList -> True; ReqAccountCreate{} -> True; ReqAccountReset{} -> True; ReqAccountDrop{} -> True; ReqIdentityAlter _ _ Nothing _ Nothing -> True; _ -> False
if not allowed || any (\a -> target == Just (accountUser a) && (accountIsSuperuser a || accountSystemCatalogManager a)) accounts
then Left "superuser required to manage privileged identities" else memoryAccounts db stamp command
ReqAccountsList -> Right accounts
ReqIdentityInitialize admin
| any (`elem` map accountUser accounts) legacyNames || length (nub legacyNames) /= length legacyNames -> Left "identity name collision"
| otherwise -> Right ([if legacyIdentity a && accountUser a == T.toLower admin then a{accountIsSuperuser = True} else a | a <- accounts] ++
[Account (highWater + offset) role "" 1 stamp Nothing False False True False | (offset, role) <- zip [1..] legacyNames])
ReqRoleCreate u
| any ((== T.toLower u) . accountUser) accounts -> Left "identity already exists"
| otherwise -> Right (accounts ++ [Account (highWater + 1) (T.toLower u) "" 1 stamp Nothing False False True False])
ReqIdentityAlter u login super enabled manager -> case filter ((== T.toLower u) . accountUser) accounts of
[_] -> preserveSuper [if accountUser a == T.toLower u then a{accountCanLogin = maybe (accountCanLogin a) id login,
accountIsSuperuser = maybe (accountIsSuperuser a) id super, accountEnabled = maybe (accountEnabled a) id enabled,
accountSystemCatalogManager = maybe (accountSystemCatalogManager a) id manager,
accountRevision = accountRevision a + 1} else a | a <- accounts] accounts
_ -> Left "unknown account"
ReqAccountCreate u h
| any ((== T.toLower u) . accountUser) accounts -> Left "account already exists"
| otherwise -> Right (accounts ++ [Account (1 + maximum (0 : map accountId accounts)) (T.toLower u) h 1 stamp Nothing])
| otherwise -> Right (accounts ++ [Account (highWater + 1) (T.toLower u) h 1 stamp Nothing True False True False])
ReqAccountReset u h -> case filter ((== T.toLower u) . accountUser) accounts of
[_] -> Right [if accountUser x == T.toLower u then x{accountHash = h, accountRevision = accountRevision x + 1} else x | x <- accounts]
_ -> Left "unknown account"
ReqAccountLogin u at -> case filter ((== T.toLower u) . accountUser) accounts of
[_] -> Right [if accountUser x == T.toLower u then x{accountLastLoginAt = at} else x | x <- accounts]
_ -> Left "unknown account"
[a] | accountEnabled a && accountCanLogin a -> Right [if accountUser x == T.toLower u then x{accountLastLoginAt = at} else x | x <- accounts]
_ -> Left "identity cannot login"
ReqAccountDrop u -> case filter ((== T.toLower u) . accountUser) accounts of
[] -> Left "unknown account"
_ -> Right [x | x <- accounts, accountUser x /= T.toLower u]
_ -> preserveSuper [x | x <- accounts, accountUser x /= T.toLower u] accounts
_ -> Left "invalid account request"
where
-- | 把一行解回账号
-- | 判断旧格式身份是否缺少属性
legacyIdentity account = any (legacyRow (accountUser account)) (maybe [] tableRows (lookup usersTable db))
-- | 检查旧身份的元数据版本
legacyRow user row = case lookup "account" row of
Just (VStr encoded) -> case A.decode (BL.pack encoded) of
Just (A.Object fields) -> KM.lookup "user" fields == Just (A.String user) && not (KM.member "is_superuser" fields)
_ -> False
_ -> False
-- | 读取旧格式账号
decodeAccount row = case lookup "account" row of
Just (VStr encoded) -> A.eitherDecode (BL.pack encoded)
_ -> Left "invalid account record"
-- | 合并身份与口令
decodeCredential row = do
identity <- decodeAccount row
case [hash | credential <- maybe [] tableRows (lookup usersTable db), lookup "id" credential == Just (VInt (fromInteger (accountId identity))), Just (VStr hash) <- [lookup "password_hash" credential]] of
[hash] -> Right identity{accountHash = T.pack hash}
_ -> Left "invalid identity credential"
-- | 保留启用的登录管理员
preserveSuper next old
| any activeSuper old && not (any activeSuper next) = Left "the last enabled login superuser cannot be removed"
| otherwise = Right next
-- | 判断可登录管理员
activeSuper a = accountEnabled a && accountCanLogin a && accountIsSuperuser a
-- | 分开保存内存身份与口令并清理授权
storeMemoryIdentities :: Database -> [Account] -> [Account] -> Database
storeMemoryIdentities db previous accounts =
(usersTable, Table usersTable [("id", TInt), ("password_hash", TStr)] credentials Nothing) :
("__system_identities", Table "__system_identities" [("account", TStr)] identities Nothing) :
("__system_identity_sequence", Table "__system_identity_sequence" [("id", TInt)] [[("id", VInt highWater)]] Nothing) :
[(name, clean name table) | (name, table) <- db, name `notElem` [usersTable, "__system_identities", "__system_identity_sequence"]]
where
-- | 保存口令与身份编号
credentials = [[("id", VInt (fromInteger (accountId a))), ("password_hash", VStr (T.unpack (accountHash a)))] | a <- accounts]
-- | 保存不含口令的身份记录
identities = [[("account", VStr (BL.unpack (A.encode a{accountHash = ""})))] | a <- accounts]
-- | 保留已用编号上界
highWater = maximum (0 : map (fromInteger . accountId) accounts ++ [n | row <- maybe [] tableRows (lookup "__system_identity_sequence" db), Just (VInt n) <- [lookup "id" row]])
-- | 清理旧目录与失效的授权边
clean name table
| name == rolesTable = table{tableRows = []}
| name `elem` [grantsTable, optionsTable, membersTable] = table{tableRows = filter active (tableRows table)}
| otherwise = table
-- | 识别已经删除的身份
removed = [accountUser a | a <- previous, accountUser a `notElem` map accountUser accounts]
-- | 排除已删除身份的授权和成员边
active row = not (any (\field -> case lookup field row of Just (VStr value) -> T.pack value `elem` removed; _ -> False) ["role", "member"])
+9 -9
View File
@@ -9,6 +9,7 @@ module ChuSQL.Server.Catalog
, ensureCatalog
, eachAction
, readRoleNames
, readIdentities
, insertRole
, dropRole
, readGrants
@@ -23,6 +24,7 @@ module ChuSQL.Server.Catalog
) where
import ChuSQL.Core.Model (Row, Value (..))
import ChuSQL.Core.Protocol (Account (..), Request (..))
import ChuSQL.Interface.Actions (sqlLiteral)
import ChuSQL.Interface.Protocol (Grant (..))
import ChuSQL.Server.Backend (Backend (..), StatementResult (..), grantsTable, membersTable, optionsTable, rolesTable)
@@ -102,21 +104,19 @@ readRows catalog table = fmap (fmap srRows) (beStatement (catBackend catalog) ("
-- | 角色名清单
readRoleNames :: Catalog -> IO (Either String [Text])
readRoleNames catalog = fmap (fmap (map (normalizeRole . cell "name"))) (readRows catalog rolesTable)
readRoleNames catalog = fmap (fmap (map accountUser)) (readIdentities catalog)
-- | 读取统一身份目录
readIdentities :: Catalog -> IO (Either String [Account])
readIdentities catalog = beAccounts (catBackend catalog) ReqAccountsList
-- | 新建一个角色
insertRole :: Catalog -> Text -> IO (Either String ())
insertRole catalog role = exec catalog (insertSql rolesTable ["name"] [normalizeRole role])
insertRole catalog role = fmap (fmap (const ())) (beAccounts (catBackend catalog) (ReqRoleCreate (normalizeRole role)))
-- | 删掉一个角色,连同它的授权与成员边
dropRole :: Catalog -> Text -> IO (Either String ())
dropRole catalog role = do
first <- exec catalog ("DELETE FROM " ++ rolesTable ++ " WHERE name = " ++ roleLiteral role)
second <- exec catalog ("DELETE FROM " ++ grantsTable ++ " WHERE role = " ++ roleLiteral role)
third <- exec catalog ("DELETE FROM " ++ membersTable ++ " WHERE role = " ++ roleLiteral role)
fourth <- exec catalog ("DELETE FROM " ++ membersTable ++ " WHERE member = " ++ roleLiteral role)
fifth <- exec catalog ("DELETE FROM " ++ optionsTable ++ " WHERE role = " ++ roleLiteral role)
pure (sequence_ [first, second, third, fourth, fifth])
dropRole catalog role = fmap (fmap (const ())) (beAccounts (catBackend catalog) (ReqAccountDrop (normalizeRole role)))
-- | 全部授权,都不带 grant option
readGrants :: Catalog -> IO (Either String [Grant])
+19 -8
View File
@@ -19,7 +19,8 @@ module ChuSQL.Server.Privileges (
) where
import ChuSQL.Core.Engine.Syntax.AST
import ChuSQL.Server.Accounts (Principal, principalIsRoot, principalName)
import ChuSQL.Core.Protocol (Account (..))
import ChuSQL.Server.Accounts (Principal, principalIsRoot, principalIsCatalogManager, principalName)
import ChuSQL.Interface.Protocol (Grant (..), RoleView (..))
import ChuSQL.Server.Backend (
Backend (..),
@@ -43,6 +44,7 @@ import ChuSQL.Server.Catalog (
readGrants,
readMembers,
readRoleNames,
readIdentities,
removeGrant,
removeGrantOption,
removeMember,
@@ -104,6 +106,10 @@ runPrivilegeCommand :: Privileges -> Principal -> Text -> PrivilegeCommand -> IO
runPrivilegeCommand service principal database command
| needsDatabase = pure (Left (PrivilegeError "no_database" "no database selected"))
| principalIsRoot principal = withTables service (applyCommand service database command)
| principalIsCatalogManager principal = case command of
CreateRoleCommand{} -> withTables service (applyCommand service database command)
DropRoleCommand{} -> pure (Left forbidden)
_ -> withTables service (delegateCommand service principal database command)
| otherwise = withTables service (delegateCommand service principal database command)
where
-- | 授权到具体对象时需要先选库
@@ -129,7 +135,7 @@ affectedAccounts service command = do
GrantRoleCommand _ members -> members
RevokeRoleCommand _ members -> members
-- | 是角色就接着往下走它的成员,走过的名字不再走第二遍
walk roles seen [] = pure (reverse seen)
walk _ seen [] = pure (reverse seen)
walk roles seen (name : rest)
| name `elem` seen = walk roles seen rest
| normalizeRole name `elem` roles = do
@@ -166,7 +172,7 @@ filterTables service principal database tables
-- | 角色总览(REST 与测试用)
listRoleViews :: Privileges -> IO (Either PrivilegeError [RoleView])
listRoleViews service = withTables service $ do
names <- roleNames service
names <- fmap (fmap (map accountUser . filter (not . accountCanLogin))) (catalog (readIdentities (pvCatalog service)))
case names of
Left err -> pure (Left err)
Right known -> sequence <$> mapM view (sort known)
@@ -221,14 +227,18 @@ grantsOfUser service user = do
members <- catalog (readMembers (pvCatalog service))
grants <- catalog (readGrants (pvCatalog service))
options <- catalog (readGrantOptions (pvCatalog service))
roles <- roleNames service
roles <- catalog (readIdentities (pvCatalog service))
pure $ do
memberRows <- members
grantRows <- grants
optionRows <- options
known <- roles
let edges = roleEdges memberRows known
mine = closureUp edges [role | (role, member) <- memberRows, member == normalizeUser user]
identities <- roles
let known = map accountUser (filter accountEnabled identities)
disabled = map accountUser (filter (not . accountEnabled) identities)
activeMembers = [(role, member) | (role, member) <- memberRows, role `elem` known, member `notElem` disabled]
edges = roleEdges activeMembers known
mine = if normalizeUser user `elem` disabled then [] else closureUp edges
(normalizeUser user : [role | (role, member) <- activeMembers, member == normalizeUser user])
pure [grant | grant <- grantRows ++ optionRows, grantRole grant `elem` mine]
-- | 一个用户手里的 grant option,只有这些能再转授
@@ -356,7 +366,7 @@ revokeOne service role privilege object = do
-- | 普通身份转授权:只放行 GRANT
delegateCommand :: Privileges -> Principal -> Text -> PrivilegeCommand -> IO (Either PrivilegeError ())
delegateCommand service principal database command = case command of
GrantPrivilegesCommand privileges object role _ -> do
GrantPrivilegesCommand _ _ role _ -> do
allowed <- requireRole service role
case allowed of
Left err -> pure (Left err)
@@ -431,4 +441,5 @@ cycleMember role member =
-- | 存储错误转成权限错误
storageError :: String -> PrivilegeError
storageError "the last enabled login superuser cannot be removed" = PrivilegeError "bad_request" "the last enabled login superuser cannot be removed"
storageError message = PrivilegeError "storage_error" ("privilege storage: " <> T.pack message)
+29 -7
View File
@@ -26,7 +26,7 @@ module ChuSQL.Server.Session
) where
import ChuSQL.Core.Model (Database, Row, Table (..), Value (..))
import ChuSQL.Core.Protocol (Account, QueryResult (..), TxnOp (..), queryResultJson)
import ChuSQL.Core.Protocol (Account (..), Request (..), QueryResult (..), TxnOp (..), queryResultJson)
import ChuSQL.Core.Engine.Storage.IPC (TableInfo (..))
import qualified ChuSQL.Core.Engine as Engine
import qualified ChuSQL.Core.Engine.Error as E
@@ -35,9 +35,9 @@ import ChuSQL.Core.Engine.Syntax.Parser (parseStatement)
import ChuSQL.Interface.AccountTable (systemTableInfo)
import ChuSQL.Interface.Auth (defaultSessionPolicy, newSessionStore)
import ChuSQL.Server.Accounts
import ChuSQL.Server.Backend (Backend (..), StatementResult (..), columnsFromStatement)
import ChuSQL.Server.Backend (Backend (..), StatementResult (..), columnsFromStatement, scopeDdl)
import ChuSQL.Server.Policy (configurePasswordPolicy)
import ChuSQL.Server.Privileges (PrivilegeCommand, PrivilegeError (..), Privileges, RoleView, affectedAccounts, authorize, filterTables, listRoleViews, newPrivileges, privilegeCommand, runPrivilegeCommand)
import ChuSQL.Server.Privileges (PrivilegeCommand (..), PrivilegeError (..), Privileges, RoleView, affectedAccounts, authorize, filterTables, listRoleViews, newPrivileges, privilegeCommand, runPrivilegeCommand)
import Control.Exception (IOException, try)
import Data.Bifunctor (first)
import Data.Char (isAlpha, isAlphaNum)
@@ -164,7 +164,7 @@ switchDatabaseCoded :: Session -> Text -> IO (Either SessionError ())
switchDatabaseCoded session rawName
| not (isPlainIdentifier name) = pure (Left (SessionError "bad_request" ("not a plain database name: " <> name)))
| name == "system" = do
admin <- sessionIsAdmin session
admin <- maybe False principalIsCatalogManager <$> readIORef (ssPrincipal session)
if admin then go else pure (Left (SessionError "forbidden" "the system database is only available to the administrator"))
| otherwise = go
where
@@ -201,6 +201,7 @@ runStatementCoded session sql = case parseStatement (T.unpack sql) of
Right (Savepoint name) -> savepointTransaction session (T.pack name)
Right (RollbackToSavepoint name) -> rollbackToSavepoint session (T.pack name)
Right (ReleaseSavepoint name) -> releaseSavepoint session (T.pack name)
Right ShowRoles -> showIdentities session
Right statement
| Just command <- accountCommand statement -> runAccount session command
| Just command <- privilegeCommand statement -> runPrivilege session command
@@ -221,6 +222,8 @@ runStatementCoded session sql = case parseStatement (T.unpack sql) of
transactionDdl :: Statement -> Bool
transactionDdl statement = case statement of
CreateTable{} -> True
CreateDomain{} -> True
DropDomain{} -> True
DropTable{} -> True
CreateIndex{} -> True
DropIndex{} -> True
@@ -347,9 +350,10 @@ savepointIndex name entries =
runInTransaction :: Session -> Statement -> IO (Either SessionError QueryResult)
runInTransaction session statement = do
existing <- readIORef (ssTransaction session)
current <- readIORef (ssCurrent session)
case existing of
Nothing -> pure (Left (SessionError "query_error" "no transaction in progress"))
Just transaction -> case Engine.runStatement (txStaged transaction) statement of
Just transaction -> case scopeDdl (T.unpack current) statement >>= Engine.runStatement (txStaged transaction) of
Left err -> pure (Left (engineErrorCode err))
Right (staged, rows) -> do
writeIORef (ssTransaction session) (Just transaction {txStaged = staged})
@@ -535,18 +539,35 @@ currentBackend session = do
name <- readIORef (ssCurrent session)
pure (beWithDatabase (ssBackend session) (T.unpack name))
-- | 账号语句走账号服务,普通账号一律拒绝;改完通知受影响账号
-- | 展示身份属性并隐藏口令
showIdentities :: Session -> IO (Either SessionError QueryResult)
showIdentities session = do
admin <- maybe False principalIsCatalogManager <$> readIORef (ssPrincipal session)
if not admin then pure (Left (SessionError "forbidden" "catalog manager required")) else do
result <- beAccounts (ssBackend session) ReqAccountsList
pure $ case result of
Left err -> Left (SessionError "storage_error" (T.pack err))
Right identities -> Right (QueryResult ["id", "name", "can_login", "is_superuser", "enabled", "system_catalog_manager", "created_at"]
[[VInt (fromInteger (accountId a)), VStr (T.unpack (accountUser a)), VBool (accountCanLogin a),
VBool (accountIsSuperuser a), VBool (accountEnabled a), VBool (accountSystemCatalogManager a), VStr (T.unpack (accountRegisteredAt a))] | a <- identities]
(length identities) False Nothing)
-- | 执行身份管理并通知受影响会话
runAccount :: Session -> AccountCommand -> IO (Either SessionError QueryResult)
runAccount session command = do
who <- readIORef (ssPrincipal session)
case who of
Nothing -> pure (Left (SessionError "unauthorized" "sign in first"))
Just principal -> do
affected <- case command of
AlterAccountAttributes name _ -> affectedAccounts (ssPrivileges session) (DropRoleCommand name)
DropAccount name -> affectedAccounts (ssPrivileges session) (DropRoleCommand name)
_ -> pure (accountCommandUsers command)
result <- runAccountCommand (ssAccounts session) principal command
case result of
Left err -> pure (Left (accountErrorOf err))
Right () -> do
notifyChanged session (accountCommandUsers command)
notifyChanged session affected
pure (Right (emptyResult Nothing))
-- | 一条账号命令影响的账号
@@ -555,6 +576,7 @@ accountCommandUsers command = case command of
CreateAccount _ _ -> []
ResetAccountPassword name _ -> [name]
DropAccount name -> [name]
AlterAccountAttributes name _ -> [name]
-- | 角色语句走权限服务,普通账号拒绝;改完通知受影响账号
runPrivilege :: Session -> PrivilegeCommand -> IO (Either SessionError QueryResult)
+7
View File
@@ -18,6 +18,7 @@ import ChuSQL.Interface.Policy (PasswordPolicy (..))
import ChuSQL.Interface.Protocol (ClientRequest (..), ServerResponse (..), decodeRequest, encodeResponse, protocolVersion)
import ChuSQL.Interface.RateLimit (RateLimiter, rateLimitBlock, rateLimitClear, rateLimitRecord)
import qualified ChuSQL.Core.Engine.Error as E
import ChuSQL.Core.Protocol (Account (..), Request (ReqIdentityInitialize))
import ChuSQL.Server.Backend (Backend (..))
import ChuSQL.Server.Session (QueryResult (..), Session, SessionError (..), accounts, authenticateSessionCoded, catalog, databases, newSessionWith, policyOf, reloadPolicy, roleViews, runStatementCoded, sessionIsAdmin, sessionUser)
import Control.Concurrent (forkIO)
@@ -116,6 +117,12 @@ data ServerEnv = ServerEnv
-- | 组装服务器环境:每个连接再各开一个会话
newServerEnv :: Backend -> Text -> FilePath -> ServerConfig -> RateLimiter -> IO ServerEnv
newServerEnv backend rootName settingsFile config limiter = do
migrated <- beAccounts backend (ReqIdentityInitialize rootName)
case migrated of
Left err -> ioError (userError ("identity migration failed: " ++ err ++ "; stop all processes and try csql-bootstrap repair --config <settings.toml>; use recover if data restoration is required"))
Right identities
| any (\a -> accountEnabled a && accountCanLogin a && accountIsSuperuser a) identities -> pure ()
| otherwise -> ioError (userError "identity migration failed: no enabled login superuser; stop all processes and try csql-bootstrap recover --config <settings.toml>; reset replaces system identities and grants")
live <- newLiveConnections
pure (ServerEnv backend rootName settingsFile config limiter live)
+278 -11
View File
@@ -5,7 +5,7 @@ module Main (main) where
import ChuSQL.Core.Engine.Storage.IPC (
Account (..),
Request (ReqAccountReset),
Request (ReqAccountReset, ReqAccountsList),
TableInfo (..),
closeConnection,
localStorageLink,
@@ -82,7 +82,7 @@ import System.IO (
noNewlineTranslation,
)
import System.Timeout (timeout)
import Test.Hspec
import Test.Hspec hiding (after, before)
-- TCP 服务器测试:协议编解码、配置读取与真连服务器的端到端用例。
@@ -98,6 +98,149 @@ spec = do
privilegeSpec
transactionSpec
ipcConcurrencySpec
identitySpec
-- | 身份属性与权限边界的端到端测试
identitySpec :: Spec
identitySpec = describe "unified identities" $ do
it "lets catalog managers manage ordinary identities without gaining superuser" $ withTcpServer defaultServerConfig $ \_ handle ->
withConnection handle $ \admin -> withConnection handle $ \manager -> do
_ <- tcpLogin admin "admin" "s3cret"
_ <- tcpQuery admin "create user manager identified by 'Worker-Pass123!'"
changed <- tcpQuery admin "alter user manager system_catalog_manager"
asText (at "status" changed) `shouldBe` "result"
signed <- tcpLogin manager "manager" "Worker-Pass123!"
asBool (at "admin" signed) `shouldBe` False
created <- tcpQuery manager "create user managed identified by 'Worker-Pass123!'"
asText (at "status" created) `shouldBe` "result"
shown <- tcpQuery manager "show roles"
asText (at "status" shown) `shouldBe` "result"
switched <- tcpQuery manager "use system"
asText (at "status" switched) `shouldBe` "result"
mapM_ (\sql -> tcpQuery manager sql >>= \reply -> asText (at "code" reply) `shouldBe` "forbidden")
["alter user manager superuser", "alter user managed system_catalog_manager", "alter user admin identified by 'Another-Pass123!'", "create database forbidden"]
it "does not inherit catalog management through role membership" $ withTcpServer defaultServerConfig $ \_ handle ->
withConnection handle $ \admin -> withConnection handle $ \worker -> do
_ <- tcpLogin admin "admin" "s3cret"
_ <- tcpQuery admin "create user worker identified by 'Worker-Pass123!'"
_ <- tcpQuery admin "create role catalog_admin"
_ <- tcpQuery admin "alter role catalog_admin system_catalog_manager"
_ <- tcpQuery admin "grant catalog_admin to worker"
_ <- tcpLogin worker "worker" "Worker-Pass123!"
denied <- tcpQuery worker "create user elevated identified by 'Worker-Pass123!'"
asText (at "code" denied) `shouldBe` "forbidden"
it "shares names and shows safe identity attributes" $ withTcpServer defaultServerConfig $ \_ handle ->
withConnection handle $ \h -> do
_ <- tcpLogin h "admin" "s3cret"
_ <- tcpQuery h "create role reader"
conflict <- tcpQuery h "create user reader identified by 'Worker-Pass123!'"
asText (at "code" conflict) `shouldBe` "conflict"
_ <- tcpQuery h "create user alice identified by 'Worker-Pass123!'"
other <- tcpQuery h "create role alice"
asText (at "code" other) `shouldBe` "conflict"
shown <- tcpQuery h "show roles"
map asText (items (at "columns" shown)) `shouldBe` ["id", "name", "can_login", "is_superuser", "enabled", "system_catalog_manager", "created_at"]
asInt (at "rowCount" shown) `shouldBe` 3
reader <- tcpQuery h "alter role reader login nologin"
asText (at "code" reader) `shouldBe` "bad_request"
it "turns a role into a login identity and promotes by attribute" $ withTcpServer defaultServerConfig $ \_ handle ->
withConnection handle $ \admin -> withConnection handle $ \worker -> do
_ <- tcpLogin admin "admin" "s3cret"
_ <- tcpQuery admin "create role worker"
_ <- tcpQuery admin "alter role worker identified by 'Worker-Pass123!'"
denied <- tcpLogin worker "worker" "Worker-Pass123!"
asText (at "status" denied) `shouldBe` "error"
changed <- tcpQuery admin "alter role worker login superuser"
asText (at "status" changed) `shouldBe` "result"
allowed <- tcpLogin worker "worker" "Worker-Pass123!"
asBool (at "admin" allowed) `shouldBe` True
created <- tcpQuery worker "create role managed"
asText (at "status" created) `shouldBe` "result"
it "does not inherit the superuser attribute through membership" $ withTcpServer defaultServerConfig $ \_ handle ->
withConnection handle $ \admin -> withConnection handle $ \alice -> do
_ <- tcpLogin admin "admin" "s3cret"
_ <- tcpQuery admin "create user alice identified by 'Worker-Pass123!'"
_ <- tcpQuery admin "create role powerful"
_ <- tcpQuery admin "alter role powerful superuser"
_ <- tcpQuery admin "grant powerful to alice"
signed <- tcpLogin alice "alice" "Worker-Pass123!"
asBool (at "admin" signed) `shouldBe` False
denied <- tcpQuery alice "create role elevated"
asText (at "code" denied) `shouldBe` "forbidden"
it "treats a demoted configured administrator as ordinary" $ withTcpServer defaultServerConfig $ \_ handle ->
withConnection handle $ \admin -> do
_ <- tcpLogin admin "admin" "s3cret"
_ <- tcpQuery admin "create user owner identified by 'Worker-Pass123!'"
_ <- tcpQuery admin "alter user owner superuser"
demoted <- tcpQuery admin "alter user admin nosuperuser"
asText (at "status" demoted) `shouldBe` "result"
withConnection handle $ \fresh -> do
signed <- tcpLogin fresh "admin" "s3cret"
asBool (at "admin" signed) `shouldBe` False
denied <- tcpQuery fresh "show roles"
asText (at "code" denied) `shouldBe` "forbidden"
it "protects the last enabled login superuser" $ withTcpServer defaultServerConfig $ \_ handle ->
withConnection handle $ \h -> do
_ <- tcpLogin h "admin" "s3cret"
mapM_ (\sql -> tcpQuery h sql >>= \reply -> asText (at "code" reply) `shouldBe` "bad_request")
["alter user admin disabled", "alter role admin nologin", "alter user admin nosuperuser", "drop user admin", "drop role admin"]
shown <- tcpQuery h "show roles"
asText (at "status" shown) `shouldBe` "result"
it "stops disabled role inheritance and disconnects its members" $ withTcpServer defaultServerConfig $ \_ handle ->
withConnection handle $ \admin -> withConnection handle $ \alice -> do
_ <- tcpLogin admin "admin" "s3cret"
_ <- tcpQuery admin "use test"
_ <- tcpQuery admin "create user alice identified by 'Worker-Pass123!'"
_ <- tcpQuery admin "create role reader"
_ <- tcpQuery admin "grant select on users to reader"
_ <- tcpQuery admin "grant reader to alice"
_ <- tcpLogin alice "alice" "Worker-Pass123!"
_ <- tcpQuery alice "use test"
before <- tcpQuery alice "select * from users"
asText (at "status" before) `shouldBe` "result"
changed <- tcpQuery admin "alter role reader disabled"
asText (at "status" changed) `shouldBe` "result"
refused <- tcpQuery alice "use test"
asText (at "code" refused) `shouldBe` "unauthorized"
tcpClosed alice
withConnection handle $ \fresh -> do
_ <- tcpLogin fresh "alice" "Worker-Pass123!"
_ <- tcpQuery fresh "use test"
after <- tcpQuery fresh "select * from users"
asText (at "code" after) `shouldBe` "forbidden"
escalation <- tcpQuery fresh "alter user alice superuser"
asText (at "code" escalation) `shouldBe` "forbidden"
it "disables login identities and invalidates their connections" $ withTcpServer defaultServerConfig $ \_ handle ->
withConnection handle $ \admin -> withConnection handle $ \alice -> do
_ <- tcpLogin admin "admin" "s3cret"
_ <- tcpQuery admin "create user alice identified by 'Worker-Pass123!'"
_ <- tcpLogin alice "alice" "Worker-Pass123!"
_ <- tcpQuery admin "alter user alice disabled"
refused <- tcpQuery alice "use test"
asText (at "code" refused) `shouldBe` "unauthorized"
tcpClosed alice
withConnection handle $ \fresh -> do
denied <- tcpLogin fresh "alice" "Worker-Pass123!"
asText (at "status" denied) `shouldBe` "error"
_ <- tcpQuery admin "alter user alice enabled"
allowed <- tcpLogin fresh "alice" "Worker-Pass123!"
asText (at "status" allowed) `shouldBe` "ok"
it "does not reuse dropped identity ids or permissions" $ withTcpServer defaultServerConfig $ \_ handle ->
withConnection handle $ \admin -> do
_ <- tcpLogin admin "admin" "s3cret"
_ <- tcpQuery admin "use test"
_ <- tcpQuery admin "create user alice identified by 'Worker-Pass123!'"
_ <- tcpQuery admin "grant select on users to alice"
before <- tcpQuery admin "show roles"
_ <- tcpQuery admin "drop user alice"
_ <- tcpQuery admin "create user alice identified by 'Worker-Pass123!'"
after <- tcpQuery admin "show roles"
at "rows" after `shouldNotBe` at "rows" before
withConnection handle $ \alice -> do
_ <- tcpLogin alice "alice" "Worker-Pass123!"
_ <- tcpQuery alice "use test"
denied <- tcpQuery alice "select * from users"
asText (at "code" denied) `shouldBe` "forbidden"
-- 夹具:内存后端装在 MVar 里,跟 Web 测试同一套表
@@ -324,14 +467,14 @@ tcpSpec = do
fmap (sum . histBuckets) (lookup "id" (tiHistograms info)) `shouldBe` Just 5
lookup "name" (tiHistograms info) `shouldBe` Nothing
describe "server config ([server] in chusql.toml)" $ do
describe "server config ([server] in settings.toml)" $ do
it "falls back to the defaults when the file has no server section" $ do
path <- tcpConfigPath "server-default"
removeIfExists path
loadServerConfigAt path `shouldReturn` defaultServerConfig
it "reads host, port, max_message and max_rows" $ do
it "reads listen_host, port, max_message and max_rows" $ do
path <- tcpConfigPath "server-keys"
BS.writeFile path "[server]\nhost = \"0.0.0.0\"\nport = 7777\nmax_message = 4096\nmax_rows = 7\n"
BS.writeFile path "[server]\nlisten_host = \"0.0.0.0\"\nport = 7777\nmax_message = 4096\nmax_rows = 7\n"
config <- loadServerConfigAt path
scHost config `shouldBe` "0.0.0.0"
scPort config `shouldBe` 7777
@@ -554,7 +697,7 @@ tcpServerSpec = do
-- | 一个普通账号
testAccount :: Account
testAccount = Account 0 "alice" "" 0 "" Nothing
testAccount = Account 0 "alice" "" 0 "" Nothing True False True False
-- | 权限用例传的当前库名
testDatabase :: Text
@@ -724,6 +867,11 @@ privilegeSpec = describe "server privileges" $ do
refused <- runPrivilegeCommand service (Ordinary testAccount) testDatabase (CreateRoleCommand "reader")
refused `shouldBe` Left (PrivilegeError "forbidden" "administrator required")
it "requires administrator privileges for domain commands" $ withPrivileges $ \service root -> do
authorizeSql service root "CREATE DOMAIN code AS INT" `shouldReturn` Right ()
authorizeSql service (Ordinary testAccount) "CREATE DOMAIN code AS INT"
`shouldReturn` Left (PrivilegeError "forbidden" "administrator required")
it "authorizes the administrator on any statement" $ withPrivileges $ \service root ->
case parseStatement "SELECT * FROM users" of
Left err -> expectationFailure err
@@ -785,12 +933,9 @@ privilegeSpec = describe "server privileges" $ do
created `shouldBe` Right ()
views <- listRoleViews service
fmap (map roleName) views `shouldBe` Right ["reader"]
-- 改名后真存储上仍要读得到角色表
stored <- beAccounts base ReqAccountsList
fmap (map (\a -> (accountUser a, accountCanLogin a))) stored `shouldBe` Right [("root", True), ("reader", False)]
let system = beWithDatabase base "system"
stored <- beStatement system "SELECT * FROM __system_roles"
case stored of
Left err -> expectationFailure ("select __system_roles: " ++ err)
Right result -> concatMap (map snd) (srRows result) `shouldContain` [VStr "reader"]
-- 账号表不给语句通道看到
denied <- beStatement system "SELECT * FROM __system_users"
case denied of
@@ -913,6 +1058,57 @@ intValues rows = [n | row <- rows, Just (VInt n) <- [nth 0 row]]
-- | 显式事务用例
transactionSpec :: Spec
transactionSpec = describe "server transactions" $ do
it "filters qualified memory tables and preserves other databases on writes" $ do
let local = Table "users" [("id", TInt)] [[("id", VInt 1)]] Nothing
otherTable = Table "users" [("id", TInt)] [[("id", VInt 2)]] Nothing
ref <- newMVar [("test.users", local), ("other.users", otherTable)]
let backend = memoryBackend "test" ref
selected <- beStatement backend "SELECT id FROM users"
fmap srRows selected `shouldBe` Right [[("id", VInt 1)]]
tables <- beCatalog backend
fmap (map tiTable) tables `shouldBe` Right ["users"]
updated <- beStatement backend "UPDATE users SET id = 3"
fmap (const ()) updated `shouldBe` Right ()
snapshot <- beSnapshot (memoryBackend "other" ref)
fmap (map (tableRows . snd)) snapshot `shouldBe` Right [[[ ("id", VInt 2) ]]]
it "rejects cross-database reads and writes including nested queries" $ withMemorySession $ \session -> do
mapM_ (\query -> do
result <- runStatementCoded session query
failureMessage result `shouldSatisfy` T.isInfixOf "outside the current database")
[ "SELECT * FROM other.users"
, "UPDATE other.users SET age = 1"
, "DELETE FROM other.users"
, "INSERT INTO other.users (id) VALUES (99)"
, "SELECT * FROM users WHERE id IN (SELECT id FROM other.users)"
, "SELECT other.users.id FROM users"
]
void (mustSql session "BEGIN")
result <- runStatementCoded session "SELECT * FROM other.users"
failureMessage result `shouldSatisfy` T.isInfixOf "outside the current database"
void (mustSql session "ROLLBACK")
it "redacts malformed password statements through the session" $ withMemorySession $ \session -> do
result <- runStatementCoded session "ALTER USER alice IDENTIFIED BY 'private-secret' trailing"
failureMessage result `shouldSatisfy` T.isInfixOf "syntax"
failureMessage result `shouldSatisfy` (not . T.isInfixOf "private-secret")
it "resolves current database qualifiers for memory DDL" $ withMemorySession $ \session -> do
_ <- mustSql session "CREATE TABLE test.qualified (id INT)"
_ <- mustSql session "ALTER TABLE test.qualified ADD COLUMN value INT DEFAULT 8"
_ <- mustSql session "INSERT INTO qualified (id) VALUES (1)"
rows <- mustSql session "SELECT value FROM test.qualified"
intValues (qrRows rows) `shouldBe` [8]
void (mustSql session "DROP TABLE test.qualified")
it "uses domain columns in snapshot transactions and rejects domain DDL" $ withMemorySession $ \session -> do
_ <- mustSql session "create domain code as int"
_ <- mustSql session "create table typed (id code)"
_ <- mustSql session "begin"
denied <- runStatementCoded session "drop domain code"
failureMessage denied `shouldBe` "DDL is not allowed in a transaction"
_ <- mustSql session "insert into typed (id) values (7)"
_ <- mustSql session "commit"
rows <- mustSql session "select id from typed"
intValues (qrRows rows) `shouldBe` [7]
it "keeps uncommitted rows invisible to other sessions until COMMIT" $ withTwoSessions $ \writer reader -> do
_ <- mustSql writer "begin"
_ <- mustSql writer "insert into users (id, name, age) values (6, 'six', 26)"
@@ -1118,6 +1314,13 @@ withIpcStorage body = do
Left err -> cleanIpc dir >> pendingWith ("cannot open the storage library: " ++ err)
Right link -> do
setStorageLink link
base <- ipcBackend
seeded <- beStorage base (A.object ["method" A..= ("bootstrap_system" :: Text), "user" A..= ("root" :: Text), "password_hash" A..= ("hash" :: Text)])
case seeded of
Left err -> expectationFailure err
Right value -> case value of
A.Object fields -> KM.lookup "initialized" fields `shouldBe` Just (A.Bool True)
_ -> expectationFailure "invalid bootstrap response"
body `finally` (closeConnection >> cleanIpc dir)
-- | 跑一条语句,出错就算用例失败
@@ -1141,6 +1344,70 @@ writeRows session ids done = do
-- | 同一进程里开两个库:会话之间不串数据,也不互相挡路
ipcConcurrencySpec :: Spec
ipcConcurrencySpec = describe "server backend on the real storage" $ do
it "resolves DDL qualifiers without crossing database boundaries" $ withIpcStorage $ do
base <- ipcBackend
mustRun (beStatement base "CREATE DATABASE alpha")
mustRun (beStatement base "CREATE DATABASE beta")
let alpha = beWithDatabase base "alpha"
beta = beWithDatabase base "beta"
unselected <- beStatement base "CREATE TABLE alpha.t (id INT)"
fmap (const ()) unselected `shouldBe` Left "no database selected"
mustRun (beStatement alpha "CREATE TABLE alpha.t (id INT, value INT)")
duplicate <- beStatement alpha "CREATE TABLE t (id INT)"
fmap (const ()) duplicate `shouldSatisfy` either (const True) (const False)
mustRun (beStatement alpha "INSERT INTO t (id, value) VALUES (1, 7)")
mustRun (beStatement alpha "CREATE INDEX ON alpha.t (value)")
mustRun (beStatement alpha "DROP INDEX ON alpha.t (value)")
mustRun (beStatement alpha "ALTER TABLE alpha.t RENAME COLUMN value TO score")
mustRun (beStatement alpha "ALTER TABLE alpha.t ALTER COLUMN score TYPE BIGINT")
mustRun (beStatement alpha "ALTER TABLE alpha.t ALTER COLUMN score SET DEFAULT 9")
mustRun (beStatement alpha "ALTER TABLE alpha.t ALTER COLUMN score SET NOT NULL")
rows <- rowsOf (beStatement alpha "SELECT score FROM t")
intColumn "score" rows `shouldBe` [7]
crossed <- beStatement beta "DROP TABLE alpha.t"
fmap (const ()) crossed `shouldBe` Left "DDL target is outside the current database: alpha.t"
mustRun (beStatement alpha "ALTER TABLE alpha.t DROP COLUMN score")
mustRun (beStatement alpha "DROP TABLE alpha.t")
catalog <- beCatalog alpha >>= either fail pure
map tiTable catalog `shouldBe` []
it "isolates domain catalogs by database and hides them from table lists" $ withIpcStorage $ do
base <- ipcBackend
unselected <- beStatement base "SHOW DOMAINS"
fmap (const ()) unselected `shouldBe` Left "no database selected"
mustRun (beStatement base "CREATE DATABASE alpha")
mustRun (beStatement base "CREATE DATABASE beta")
let alpha = beWithDatabase base "alpha"
beta = beWithDatabase base "beta"
mustRun (beStatement alpha "CREATE DOMAIN code AS INT")
mustRun (beStatement beta "CREATE DOMAIN code AS VARCHAR(3)")
mustRun (beStatement alpha "CREATE TABLE typed (id code)")
mustRun (beStatement beta "CREATE TABLE typed (id code)")
mustRun (beStatement alpha "INSERT INTO typed (id) VALUES (1)")
mustRun (beStatement beta "INSERT INTO typed (id) VALUES ('abc')")
catalog <- beCatalog alpha >>= either fail pure
map tiTable catalog `shouldBe` ["typed"]
domains <- rowsOf (beStatement beta "SHOW DOMAINS")
domains `shouldBe` [[("domain", VStr "code"), ("base_type", VStr "varchar(3)")]]
refused <- beStatement beta "INSERT INTO typed (id) VALUES (1)"
fmap (const ()) refused `shouldSatisfy` either (const True) (const False)
it "resolves nested domains only inside their own database" $ withIpcStorage $ do
base <- ipcBackend
mustRun (beStatement base "CREATE DATABASE alpha")
mustRun (beStatement base "CREATE DATABASE beta")
let alpha = beWithDatabase base "alpha"
beta = beWithDatabase base "beta"
mustRun (beStatement alpha "CREATE DOMAIN label AS VARCHAR(3)")
mustRun (beStatement alpha "CREATE DOMAIN product_label AS label")
absent <- beStatement beta "CREATE DOMAIN product_label AS label"
fmap (const ()) absent `shouldBe` Left "unknown domain: label"
mustRun (beStatement alpha "CREATE TABLE products (name product_label)")
mustRun (beStatement alpha "INSERT INTO products (name) VALUES ('abc')")
rowsOf (beStatement alpha "SELECT name FROM products") `shouldReturn` [[("name", VStr "abc")]]
dependency <- beStatement alpha "DROP DOMAIN label"
fmap (const ()) dependency `shouldSatisfy` either (const True) (const False)
it "keeps two databases apart" $ withIpcStorage $ do
base <- ipcBackend
mustRun (beStatement base "CREATE DATABASE alpha")
+21 -28
View File
@@ -18,12 +18,11 @@ import ChuSQL.Interface.Config (
import ChuSQL.Interface.Link (clientPing, closeClient, connectClient)
import ChuSQL.Interface.RateLimit (newRateLimiter)
import ChuSQL.Interface.Session (authenticateSession, newSession)
import ChuSQL.Interface.Settings (effectiveSettings, readSettingsFile)
import ChuSQL.Interface.Settings (effectiveSettings)
import ChuSQL.Interface.TOML (resolveConfigPath)
import ChuSQL.Web.Demo (SeedReport (..), seedDemo)
import Control.Exception (IOException, try)
import Data.Char (toLower)
import qualified Data.Map.Strict as Map
import Data.String (fromString)
import Data.Text (Text)
import qualified Data.Text as T
@@ -42,7 +41,6 @@ data Options = Options
{ optConfig :: Maybe FilePath
, optHost :: Maybe String
, optPort :: Maybe Int
, optStatic :: Maybe FilePath
, optUser :: Maybe Text
, optCookieSecure :: Maybe Bool
, optBodyLimit :: Maybe Int
@@ -65,7 +63,6 @@ emptyOptions =
{ optConfig = Nothing
, optHost = Nothing
, optPort = Nothing
, optStatic = Nothing
, optUser = Nothing
, optCookieSecure = Nothing
, optBodyLimit = Nothing
@@ -90,8 +87,7 @@ parseArgs = go emptyOptions
go opts ("--help" : rest) = go opts{optHelp = True} rest
go opts ("-h" : rest) = go opts{optHelp = True} rest
go opts ("--config" : v : rest) = go opts{optConfig = Just v} rest
go opts ("--host" : v : rest) = go opts{optHost = Just v} rest
go opts ("--static" : v : rest) = go opts{optStatic = Just v} rest
go opts ("--listen-host" : v : rest) = go opts{optHost = Just v} rest
go opts ("--user" : v : rest) = go opts{optUser = Just (T.pack v)} rest
go opts ("--port" : v : rest) = withInt "--port" v (\n -> go opts{optPort = Just n} rest)
go opts ("--body-limit" : v : rest) = withInt "--body-limit" v (\n -> go opts{optBodyLimit = Just n} rest)
@@ -120,17 +116,16 @@ usage :: IO ()
usage = do
putStrLn "usage: chusql-web [options]"
putStrLn ""
putStrLn "config: chusql.toml sits at a fixed place and its sections hold every knob below;"
putStrLn " Windows: %APPDATA%\\ChuSQL\\chusql.toml"
putStrLn " other: $XDG_CONFIG_HOME/ChuSQL/chusql.toml (or ~/.config/ChuSQL/chusql.toml)"
putStrLn " [web] host/port/static_dir/user (administrator name) and the limits;"
putStrLn " [storage] data_dir; [page] size; [btree] order; [buffer] pool_size; [log] level"
putStrLn "config: settings.toml sits at a fixed place and its sections hold every knob below;"
putStrLn " Windows: %APPDATA%\\ChuSQL\\settings.toml"
putStrLn " other: $XDG_CONFIG_HOME/ChuSQL/settings.toml (or ~/.config/ChuSQL/settings.toml)"
putStrLn " [web] listen_host/port and the limits;"
putStrLn " [storage] data_dir/log_files; [page] size; [btree] order; [buffer] pool_size; [log] level"
putStrLn ""
putStrLn " --config FILE read that config file instead ([web] host ...)"
putStrLn " --host H listen address ([web] host, default 127.0.0.1)"
putStrLn " --config FILE read that config file instead ([web] listen_host ...)"
putStrLn " --listen-host H listen address ([web] listen_host, default 127.0.0.1)"
putStrLn " --port N listen port ([web] port, default 7778)"
putStrLn " --static DIR static asset directory ([web] static_dir, default static)"
putStrLn " --user NAME administrator name ([web] user, default root)"
putStrLn (" --user NAME administrator name (default " ++ T.unpack defaultUser ++ ")")
putStrLn " --cookie-secure BOOL add Secure to the cookie ([web] cookie_secure, default false)"
putStrLn " --body-limit N max request body bytes ([web] body_limit, default 65536)"
putStrLn " --session-idle N session idle timeout, sec ([web] session_idle, default 28800)"
@@ -143,13 +138,13 @@ usage = do
putStrLn " --max-sql-length N max SQL characters ([web] max_sql_length, default 20000)"
putStrLn " --seed BOOL load demo data when the db is empty ([web] seed, default false)"
putStrLn ""
putStrLn "administrator password: lives in the __system_users system table, never in chusql.toml"
putStrLn "administrator password: lives in the __system_users system table, never in settings.toml"
putStrLn " the installer always sets one; an account whose hash is still empty signs in with an empty password"
putStrLn (" the [web] user key only picks the administrator name (default " ++ T.unpack defaultUser ++ ")")
putStrLn " the administrator name comes from --user; settings.toml does not carry it"
putStrLn "ordinary accounts: CREATE USER / ALTER USER / DROP USER from the SQL console (administrator only)"
putStrLn "storage: the server owns the data directory and loads the chusql_core_storage library;"
putStrLn " this process is a plain TCP client: every statement, lookup and account request goes to it"
putStrLn " [server] host / port says where it listens; [storage] data_dir, [page] size, [btree] order,"
putStrLn " [server] listen_host / port says where it listens; [storage] data_dir, [page] size, [btree] order,"
putStrLn " [buffer] pool_size and [log] level are the server's own"
-- | 命令行覆盖配置
@@ -158,7 +153,6 @@ applyOptions cfg opts =
cfg
{ wcHost = orElse (optHost opts) (wcHost cfg)
, wcPort = orElse (optPort opts) (wcPort cfg)
, wcStaticDir = orElse (optStatic opts) (wcStaticDir cfg)
, wcUser = orElse (optUser opts) (wcUser cfg)
, wcCookieSecure = orElse (optCookieSecure opts) (wcCookieSecure cfg)
, wcBodyLimit = orElse (optBodyLimit opts) (wcBodyLimit cfg)
@@ -206,7 +200,6 @@ run opts = do
Just staticDir -> do
let settingsFile = configPath
policy = SessionPolicy (fromIntegral (wcSessionIdle cfg)) (fromIntegral (wcSessionMax cfg))
saved <- readSettingsFile settingsFile
legacy <- legacyPasswordKey settingsFile
config <- loadServerConfigAt settingsFile
limiter <-
@@ -225,9 +218,9 @@ run opts = do
setLive env (liveFromConfig cfg)
app <- webApp env
printBanner cfg configPath staticDir
reportCredential cfg saved legacy
reportCredential cfg legacy
checkServer (aeServerHost env) (aeServerPort env)
seedIfWanted cfg saved (aeServerHost env) (aeServerPort env)
seedIfWanted cfg (aeServerHost env) (aeServerPort env)
let settings =
Warp.setPort (wcPort cfg) (Warp.setHost (fromString (wcHost cfg)) Warp.defaultSettings)
serve settings app
@@ -260,9 +253,9 @@ serve settings app = do
exitFailure
-- | 报告管理员账号状态:配置里还留着明文口令键就提醒
reportCredential :: WebConfig -> Map.Map Text Text -> Bool -> IO ()
reportCredential cfg saved legacy = do
putStrLn ("administrator: " ++ T.unpack (rootUserName cfg saved))
reportCredential :: WebConfig -> Bool -> IO ()
reportCredential cfg legacy = do
putStrLn ("administrator: " ++ T.unpack (rootUserName cfg))
if legacy
then do
putStrLn " !! the [web] section still carries a plaintext password key; it is ignored now"
@@ -270,8 +263,8 @@ reportCredential cfg saved legacy = do
else pure ()
-- | 配了 seed 就补演示数据(管理员已设口令时跳过)
seedIfWanted :: WebConfig -> Map.Map Text Text -> Text -> Int -> IO ()
seedIfWanted cfg saved host port
seedIfWanted :: WebConfig -> Text -> Int -> IO ()
seedIfWanted cfg host port
| not (wcSeedDemo cfg) = pure ()
| otherwise = do
opened <- connectClient host port
@@ -279,7 +272,7 @@ seedIfWanted cfg saved host port
Left message -> TIO.putStrLn ("demo data skipped: " <> message)
Right client -> do
session <- newSession client
signed <- authenticateSession session (rootUserName cfg saved) ""
signed <- authenticateSession session (rootUserName cfg) ""
case signed of
Left message -> TIO.putStrLn ("demo data skipped: " <> message <> " (set the administrator password after seeding)")
Right () -> do
+5 -5
View File
@@ -2,11 +2,11 @@ cabal-version: 2.4
name: chusql-web
version: 1.0.0
synopsis: ChuSQL Web 管理端:Scotty + REST API + 静态页面
description: ChuSQL 的浏览器管理端。负责 HTTP 交付、鉴权、静态文件,
并把用户的 SQL 经 TCP 转交给 chusql-server(存储由服务器进程里的
chusql_core_storage 动态库承担)。本进程不链引擎,也不碰存储。
数据库服务器与 TCP 协议在 chusql-server 包里,这里只做 HTTP 这一面。
synopsis: ChuSQL web administration with Scotty, REST APIs, and static pages
description: Provides the browser administration interface, HTTP APIs,
authentication, and static file delivery. Forwards SQL to
chusql-server over TCP; query execution and storage remain in
the database server process.
build-type: Simple
library
+2 -2
View File
@@ -229,7 +229,7 @@ reservedDatabase name = name == systemDatabase
resolveSettingsFileSafe :: IO FilePath
resolveSettingsFileSafe = do
found <- try defaultConfigFile :: IO (Either IOException FilePath)
pure (either (const "chusql.toml") id found)
pure (either (const "settings.toml") id found)
-- | 定位 IDE 设置文件,找不到就定下首个候选路径
resolveUISettingsFileSafe :: IO FilePath
@@ -403,7 +403,7 @@ indexH env = do
case content of
Nothing -> do
status status500
json (apiErrorJson "missing_static" "index.html not found (check the --static directory)")
json (apiErrorJson "missing_static" "index.html not found in the static directory")
Just page -> do
nonce <- liftIO sessionToken
let (beforeHead, afterHead) = BS.breakSubstring "</head>" page
+3 -3
View File
@@ -22,13 +22,13 @@ import qualified Data.Vector as V
import System.Directory (createDirectoryIfMissing, doesDirectoryExist, doesFileExist)
import System.FilePath (takeDirectory, (</>))
-- 前端 IDE 设置单独存一份文件,与全局启动配置 chusql.toml 互不干扰。
-- 前端 IDE 设置单独存一份文件,与全局启动配置 settings.toml 互不干扰。
-- | IDE 设置文件的候选位置
uiSettingsFileCandidates :: [FilePath]
uiSettingsFileCandidates =
[ "scripts" </> "chusql.ui.settings.json"
, ".." </> "scripts" </> "chusql.ui.settings.json"
[ "resources" </> "chusql.ui.settings.json"
, ".." </> "resources" </> "chusql.ui.settings.json"
, "chusql.ui.settings.json"
]
+1 -1
View File
@@ -587,7 +587,7 @@ function renderSettingsTab(el) {
el.innerHTML = `
<div class="settings-view">
<header><h1>设置</h1><p class="muted">IDE 设置存在 chusql.ui.settings.json,服务器设置写回 chusql.toml 的 [web] 分区。</p></header>
<header><h1>设置</h1><p class="muted">IDE 设置存在 chusql.ui.settings.json,服务器设置写回 settings.toml 的 [web] 分区。</p></header>
<fieldset class="settings-group"><legend>界面</legend>
${fontField('uiFonts')}${numberField('uiFontSize')}
</fieldset>
+21 -10
View File
@@ -20,10 +20,10 @@ readUtf8 path = TE.decodeUtf8 <$> BS.readFile path
gateSpec :: Spec
gateSpec = do
describe "Installer scripts (scripts/)" $ do
it "install.sh releases files, writes chusql.toml, sets PATH and removes the package" $ do
it "install.sh releases files, writes settings.toml, sets PATH and removes the package" $ do
installer <- readUtf8 (".." </> "scripts" </> "install.sh")
installer `shouldSatisfy` T.isInfixOf "libchusql_core_storage"
installer `shouldSatisfy` T.isInfixOf "chusql.toml"
installer `shouldSatisfy` T.isInfixOf "settings.toml"
installer `shouldSatisfy` T.isInfixOf "PATH"
installer `shouldSatisfy` T.isInfixOf "rm -rf"
-- 一份脚本三种来源:包内(旁边有 bin/)、在线(管道进来时 $0 是 sh,按平台取 release 资源)、
@@ -138,20 +138,26 @@ gateSpec = do
ps `shouldSatisfy` T.isInfixOf "chusql-$Platform"
ps `shouldSatisfy` T.isInfixOf "chusql-web\\static"
ps `shouldSatisfy` T.isInfixOf "bin\\csql.exe"
-- 配置模板跟组件目录走:包内目录叫 resources,不再是 scripts
ps `shouldSatisfy` T.isInfixOf "resources\\settings.toml.windows"
ps `shouldSatisfy` (not . T.isInfixOf "scripts\\settings.toml")
sh `shouldSatisfy` T.isInfixOf "resources/settings.toml.linux"
sh `shouldSatisfy` (not . T.isInfixOf "scripts/settings.toml")
ps `shouldSatisfy` T.isInfixOf "Test-Installed $name 'cli'"
ps `shouldSatisfy` (not . T.isInfixOf "$Component")
ignore <- readUtf8 (".." </> ".gitignore")
ignore `shouldSatisfy` T.isInfixOf "releases/"
-- static_dir 是相对路径,启动器必须先切到安装目录;少了这一句,从别处执行
-- 静态目录固定在安装目录下,启动器必须先切到安装目录;少了这一句,从别处执行
-- csql-web 就会因为找不到 static/ 直接退出(Windows 的 .ps1 靠 -WorkingDirectory)。
it "csql-web.sh switches to the install dir before starting" $ do
launcher <- readUtf8 (".." </> "scripts" </> "csql-web.sh")
launcher `shouldSatisfy` T.isInfixOf "cd \"$home_dir\""
launcher `shouldSatisfy` T.isInfixOf "chusql-server"
-- 默认数据目录按平台惯例算,三处必须说同一件事:Rust 代码、配置示例、模板。
-- 跟安装脚本装的位置也是一处:Windows 装到 %LOCALAPPDATA%\ChuSQL、Unix 装到 ~/.local/share/chusql,
-- 数据都放在它下面的 data/ 里。
-- 日志目录由 [storage] log_files 决定,不再是硬编码的 logs
launcher `shouldSatisfy` T.isInfixOf "log_files"
-- 默认数据目录按平台惯例算,两处必须说同一件事:Rust 代码与配置示例。
-- 模板路径占位符在安装时填入绝对路径。
it "the default data dir follows the platform, and code and template agree" $ do
rust <- readUtf8 (".." </> "chusql-core" </> "storage" </> "src" </> "config.rs")
rust `shouldSatisfy` T.isInfixOf "default_data_dir"
@@ -165,10 +171,15 @@ gateSpec = do
exampleFile `shouldSatisfy` T.isInfixOf "%LOCALAPPDATA%"
exampleFile `shouldSatisfy` T.isInfixOf "XDG_DATA_HOME"
template <- readUtf8 (".." </> "scripts" </> "chusql.toml")
template <- readUtf8 (".." </> "resources" </> "settings.toml.linux")
-- 模板那行是安装时被 sed / -replace 替换的占位,必须保持能生效
template `shouldSatisfy` T.isInfixOf "\ndata_dir = "
template `shouldSatisfy` T.isInfixOf "%LOCALAPPDATA%"
-- 日志跟数据分家:启动器与安装脚本按 log_files 决定日志落哪个目录
template `shouldSatisfy` T.isInfixOf "log_files = \"@LOG_DIR@\""
-- 静态目录与管理员名都不再写进配置:前者写死 static,后者由 --user 给(默认 root);
-- 监听地址是活的配置项([server] listen_host / [web] listen_host),模板里可以留空靠内置默认
template `shouldSatisfy` (not . T.isInfixOf "static_dir =")
template `shouldSatisfy` (not . T.isInfixOf "user =")
-- 管道传输退休后,两侧都不该再算套接字路径、也不该再认管名。
it "neither side computes a socket path or a pipe name any more" $ do
haskellIpc <- readUtf8 (".." </> "chusql-core" </> "engine" </> "src" </> "ChuSQL" </> "Core" </> "Engine" </> "Storage" </> "IPC.hs")
@@ -217,8 +228,8 @@ gateSpec = do
release `shouldSatisfy` (not . T.isInfixOf "component: [web, cli]")
it "no layer looks for a storage executable or a pipe name any more" $ do
engine <- readUtf8 (".." </> "chusql-core" </> "engine" </> "test" </> "Spec.hs")
bench <- readUtf8 (".." </> "chusql-benchmark" </> "src" </> "Main.hs")
template <- readUtf8 (".." </> "scripts" </> "chusql.toml")
bench <- readUtf8 (".." </> "benchmark" </> "src" </> "Main.hs")
template <- readUtf8 (".." </> "resources" </> "settings.toml.linux")
mapM_
(\src -> src `shouldSatisfy` (not . T.isInfixOf "pipe_name"))
[engine, bench, template]
+19 -14
View File
@@ -9,6 +9,7 @@ import ChuSQL.Interface.Settings (
applySettings,
defaultOf,
isLockedSetting,
isRestartRequired,
liveKeys,
readSettingsFile,
settingCatalogue,
@@ -382,12 +383,12 @@ startLiveWorld label = do
uniq <- uniqueSuffix
let root = tmp </> ("chusql-web-live-" ++ label ++ "-" ++ uniq)
dataDir = root </> "data"
config = root </> "chusql.toml"
config = root </> "settings.toml"
createDirectoryIfMissing True dataDir
writeFile config (serverConfigText dataDir)
seedSystemCatalog config
(_, Just out, _, process) <-
createProcess (proc bin ["--config", config]){std_out = CreatePipe, std_err = NoStream}
createProcess (proc bin ["--config", config, "--user", T.unpack adminUser]){std_out = CreatePipe, std_err = NoStream}
port <- readListeningPort out
_ <- forkIO (drainHandle out)
setAdminPassword port
@@ -416,12 +417,11 @@ serverConfigText :: FilePath -> String
serverConfigText dataDir =
unlines
[ "[web]"
, "user = \"" ++ T.unpack adminUser ++ "\""
, "password-min-length = 8"
, "password-classes = 2"
, ""
, "[server]"
, "host = \"127.0.0.1\""
, "listen_host = \"127.0.0.1\""
, "port = 0"
, ""
, "[storage]"
@@ -472,7 +472,7 @@ locateServerExe = do
seedSystemCatalog :: FilePath -> IO ()
seedSystemCatalog config = do
bootstrap <- locateBootstrapExe
(code, out, errOut) <- readProcessWithExitCode bootstrap ["--config", config, "--passwordless"] ""
(code, out, errOut) <- readProcessWithExitCode bootstrap ["--config", config, "--passwordless", "--user", T.unpack adminUser] ""
case code of
ExitSuccess -> pure ()
_ -> fail ("the fixture bootstrap program failed (exit " ++ show code ++ "): " ++ out ++ errOut)
@@ -541,21 +541,23 @@ unitSpec = describe "pure helpers" $ do
it "round-trips settings through a real TOML file" $ do
path <- tempFilePath "settings"
removeIfExists path
writeSettingsFile path (Map.fromList [("rows-per-page", "50"), ("user", "admin")])
writeSettingsFile path (Map.fromList [("rows-per-page", "50"), ("log-files", "./logs")])
>>= (`shouldSatisfy` isRightE)
values <- readSettingsFile path
Map.lookup "rows-per-page" values `shouldBe` Just "50"
Map.lookup "user" values `shouldBe` Just "admin"
Map.lookup "log-files" values `shouldBe` Just "./logs"
it "rejects unknown settings and out-of-range limits" $ do
applySettings Map.empty (Map.fromList [("nope", "1")]) `shouldSatisfy` isLeftE
applySettings Map.empty (Map.fromList [("password-min-length", "3")]) `shouldSatisfy` isLeftE
applySettings Map.empty (Map.fromList [("password-min-length", "12")]) `shouldSatisfy` isRightE
it "marks the administrator name as locked and lists the live keys" $ do
isLockedSetting "user" `shouldBe` True
it "lists the live keys and locks nothing" $ do
isLockedSetting "user" `shouldBe` False
isLockedSetting "rows-per-page" `shouldBe` False
("rows-per-page" `elem` liveKeys) `shouldBe` True
length settingCatalogue `shouldSatisfy` (> 20)
defaultOf "rows-per-page" `shouldBe` "25"
defaultOf "log-files" `shouldBe` "./logs"
isRestartRequired "log-files" `shouldBe` True
it "validates IDE settings" $ do
validateUISettings (object ["sqlFontSize" .= (13 :: Int), "minimap" .= False])
`shouldSatisfy` isRightE
@@ -753,7 +755,9 @@ settingsSpec env = describe "settings and password policy" $ do
let entries = items (at "items" (jsonBody res))
check (length entries `shouldSatisfy` (> 20))
check (asText (at "value" (headEntry "rows-per-page" entries)) `shouldBe` "25")
check (at "locked" (headEntry "user" entries) `shouldBe` A.Bool True)
-- 设置目录里没有任何只读键(管理员名已不写进配置),新的日志目录项在列
check (at "locked" (headEntry "rows-per-page" entries) `shouldBe` A.Bool False)
check (asText (at "value" (headEntry "log-files" entries)) `shouldBe` "./logs")
it "writes a setting, makes the server reload the policy, and reports it applied" $ do
hs <- adminHeaders
res <-
@@ -777,13 +781,14 @@ settingsSpec env = describe "settings and password policy" $ do
check (asInt (at "minLength" (at "policy" (jsonBody me))) `shouldBe` 16)
values <- liftIO (readSettingsFile (aeSettingsFile env))
check (Map.lookup "password-min-length" values `shouldBe` Just "16")
check (Map.lookup "user" values `shouldBe` Just adminUser)
it "refuses unknown settings and the locked administrator name" $ do
-- 管理员名不再落进配置文件:它由 --user 给
check (Map.member "user" values `shouldBe` False)
it "refuses unknown settings, including the retired administrator name" $ do
hs <- adminHeaders
unknown <- putAs hs "/api/settings" (encode (object ["values" .= object ["nope" .= ("1" :: Text)]]))
expectStatusWith "unknown setting" unknown 400
locked <- putAs hs "/api/settings" (encode (object ["values" .= object ["user" .= ("hacked" :: Text)]]))
expectStatusWith "locked administrator name" locked 403
retired <- putAs hs "/api/settings" (encode (object ["values" .= object ["user" .= ("hacked" :: Text)]]))
expectStatusWith "retired administrator name" retired 400
-- 账号表 ---------------------------------------------------------------------
+81 -42
View File
@@ -1,72 +1,111 @@
# 架构与组件
ChuSQL 采用客户端 / 服务端架构。数据库文件仅由 `chusql-server` 直接访问,其他组件通过网络接口与数据库服务通信。
ChuSQL 采用客户端 / 服务端架构。正常运行时,只有数据库服务直接访问数据文件;离线初始化与恢复由 bootstrap 完成。
## 主要组件
## 组件与访问路径
| 组件 | 作用 |
| --- | --- |
| `chusql-server` | 数据库服务,负责 SQL 执行、权限检查、事务与数据访问 |
| `chusql-web` | Web 管理端,通过数据库服务访问数据 |
| `csql` | 命令行客户端,通过数据库服务执行 SQL |
| `chusql-core/engine` | SQL 解析、语义检查、优化与执行 |
| `chusql-core/storage` | 页面、索引、日志与持久化存储 |
## 数据流
典型访问路径为:
```text
~~~text
Web / CLI / 其他客户端
↓
TCP
↓
↓ TCP
chusql-server
↓
SQL 引擎与存储层
↓
磁盘
```
SQL 引擎 → Rust 存储层 → 磁盘
~~~
所有 SQL 和权限检查均由数据库服务统一处理。Web 和 CLI 不直接访问数据目录,因此多个客户端可以同时连接同一数据库服务。
| 组件 | 职责 |
| --- | --- |
| chusql-server | 登录、会话、鉴权、SQL 执行和事务 |
| chusql-web | Web 管理界面,通过 TCP 访问服务 |
| csql | 命令行客户端,通过 TCP 执行 SQL |
| chusql-core/engine | 解析、语义检查、优化及执行 |
| chusql-core/storage | catalog、页面、B+ 树、缓存、WAL 与持久化 |
| csql-bootstrap | 安装引导及离线系统目录修复、恢复、重置 |
服务默认监听 127.0.0.1:7777,Web 默认监听 127.0.0.1:7778。配置位置和平台数据目录见 [config.md](config.md)。
## 数据目录
数据保存在 `[storage] data_dir` 指定的位置,主要包含:
[storage] data_dir 是持久化数据的根目录,诊断日志由 log_files 单独指定。
| 目录 | 内容 |
| 位置 | 内容 |
| --- | --- |
| `system/` | 系统数据库,包括账号和权限等系统信息 |
| `databases/<数据库名>/` | 用户创建的数据库 |
| system/ | 身份、凭据、角色、授权和预定义类型目录 |
| databases/<数据库名>/ | 业务数据库的 catalog、数据与索引 |
| WAL 与检查点文件 | 已提交写入的恢复记录及刷盘进度 |
安装完成后只有系统数据库,不自动创建业务数据库。业务数据库需通过 `CREATE DATABASE` 创建。
安装只初始化系统数据库,业务数据库由用户创建。表存在性以 catalog 为准,不能仅凭数据文件判断。
## 数据库服务
## 会话与并发
`chusql-server` 默认监听 `127.0.0.1:7777`,客户端建立连接后完成登录、数据库选择和 SQL 执行。
- 每条 TCP 连接拥有独立会话,当前数据库和事务互不影响。
- 读请求可并行,写请求由存储层协调。
- Web 与 CLI 共享服务端的权限和执行规则,不直接读写数据目录。
每条连接拥有独立会话状态,例如当前数据库选择不会影响其他连接。不同连接可以同时执行语句:读请求之间可以并行,写请求之间由存储层排队。
## SQL 查询处理
语句里的库前缀和表限定符由引擎在语义层解析:存储层只接受精确的表名键,因此 `FROM sales.orders` 与选定 `sales` 后的 `FROM orders` 指向同一张表。FROM 的来源是一棵树:单表、派生表(括号里的子查询,必须带别名)与连接共用同一个 `FromClause`,所以 `JOIN` 两侧都能是派生表。解析派生表时先把内层语句在空作用域里解析成一张“表”——内层不能引用外层列,输出列统一改成裸列名(内层 `u.id` 在外层用 `d.id` 引用),重名直接报错;执行侧对应一个 `Derived` 算子,把内层计划的结果按别名加前缀。派生表不参与连接顺序重排(叶子的行数未知),投影也不下推进去,但内层计划照常做谓词下推与索引改写。投影项的 `AS` 别名与 `SELECT` 的输出列名一起在语义层解析,派生表与最外层查询都按别名暴露列名。
查询依次经过解析、基于 schema 的语义检查、优化和执行。库名、表名、列名及别名在语义层解析;存储层使用精确的表名键。
`WITH` 不新增语法树节点:解析器在读到一个 `WITH` 时就把每个 CTE 的名字与语句记进一张环境表,遇到 `FROM` 里出现的 CTE 名(不分大小写,只认前面已定义的)就换成 M11 的派生表节点,主语句与 CTE 内部都能再写 `WITH`。这样展开出来的语句对语义层、执行器与鉴权完全等价于手写派生表:CTE 用列清单改名走派生表的投影别名,来源树与权限覆盖不必另做一套。代价是 CTE 不做物化(引用两次就执行两次)、不支持递归,且前向引用会当成普通表名去查。
| 能力 | 实现与边界 |
| --- | --- |
| 派生表 | 内层独立解析,必须有别名,不能引用外层列;输出列按别名暴露 |
| WITH / CTE | 解析时展开为派生表;不支持递归或物化,多次引用分别执行 |
| ANY / ALL | 按 OR / AND 的三值逻辑求值;空集的 ANY 为假、ALL 为真 |
| 子查询复用 | Filter 和计算列按外层绑定分组;JOIN 条件按首次遇到的绑定惰性复用 |
| 成本估算 | 使用行数、不同值数和数值列的 16 桶等宽直方图 |
| 索引选择 | 优化器判断适用条件与成本,存储层管理实际索引 |
量词比较走另一个真节点 `QuantCmp`:解析时普通比较运算符先读最长符号,发现后面是 `ANY` / `ALL` 就让给量词解析,因此 `age > ANY (SELECT …)` 与 `age > 25` 共用同一层优先级。求值时先把子查询的行取成字面量,再摊成 `AND`(`ALL`)/ `OR`(`ANY`)链交给既有三值逻辑,于是空子查询 `ANY` 为假、`ALL` 为真,子查询里的 `NULL` 按三值规则传播。节点不带取反位(本方言没有前缀 `NOT`),取反写相反的运算符,如 `<> ALL` 与 `NOT IN` 同义。
派生表可根据 schema 和透明算子估计行数并参与安全的连接重排;缺统计或可能报错时保守处理。输出列投影可下推,但保留所有计算项的求值,避免隐藏错误。内存存储提供实时行数和封顶去重统计。JOIN 条件的子查询在首次遇到外层绑定时求值,缓存仅限当前连接;空行对不触发求值,普通条件仍逐行计算。尚无半连接或通用物化。统计信息只影响计划选择,优化不能改变查询结果语义。
优化器的成本估算用存储维护的统计:行数、列级不同值数,以及数值列的列级等宽直方图。直方图是 16 个桶加区间下界 / 上界,只统计 `int` 与 `float` 列(NULL 与非数值不进直方图);存储侧随插入累计、随删除递减,值跑出区间就把旧桶按中点并进新区间,删到表空时清空。这些统计随 schema 下沉到引擎,`Cost.rangeRows` 有直方图就按桶与区间的交集估命中行数(端点按闭区间算),没有直方图退回原来的四分之一;`Cost.selectivityOf` 把这个比例交给连接顺序估算与索引改写的成本把关,因此一条覆盖大半张表的范围条件不会再被改写成索引点查。在线协议里每条列统计多出 `lo` / `hi` / `hist` 三个可选字段,缺省即「没有直方图」,老客户端与老字典都能照常读。
完整 SQL 写法及限制见 [syntax.md](syntax.md)。
子查询按外层绑定分组求值:`Filter` 与计算列两个算子入口先看条件里有没有子查询,有就按子查询引用的外层列取值给扫描到的行分组,同一绑定只把子查询换一次(`IN` / `EXISTS` / 标量 / 量词四种位置共用),然后各行的条件与表达式都用换好的那份求值。非相关子查询没有外层列,因此整条语句只跑一次子查询;相关子查询按不同绑定各跑一次(外层 3 行但绑定只有 2 种就只跑 2 次)。没有子查询的行仍走原来的逐行求值,JOIN 条件与更深的表达式位置不做分组。
## 快照事务
显式事务在会话层实现:`BEGIN` 把当前数据库整库读成内存快照,事务内的语句都在快照上执行;`SAVEPOINT` 把当前快照压进会话里的保存点栈,`ROLLBACK TO` 退回该点并保留它,`RELEASE` 丢掉该点与其后的保存点;`COMMIT` 先把快照、基线与库里现值按行 `id` 逐键比对,检测到写写冲突就报 `serialization_failure` 并保留事务;没有冲突才把差分用一条 `apply_transaction` 请求交给存储层应用;`ROLLBACK` 直接丢弃快照。因此隔离级别是快照读加原子提交:事务里看不到别的事务未提交或后来提交的改动,提交对外一次性可见,冲突由调用方回滚或重试。
| 阶段 | 行为 |
| --- | --- |
| BEGIN | 将当前数据库读为会话内存快照 |
| 事务内执行 | 读取快照与自身修改,看不到其他连接后续提交 |
| SAVEPOINT | 保存当前快照;回滚可退回指定保存点 |
| COMMIT | 比对基线与现值,确认无写写冲突后原子应用差分 |
| ROLLBACK | 丢弃会话快照与保存点 |
WAL 负责崩溃恢复:每条记录带自增 LSN,一组写操作与一条提交标记一次落盘;每组提交在数据和字典都刷盘之后把 `wal.checkpoint` 推进到这条提交的 LSN,含义是「到此为止的改动都已落盘」,再按这个边界丢掉日志前缀——所以日志里通常只有当组在飞的记录。进程重启时只重放检查点之后的记录,按标记分组取已提交的组、丢弃没有标记的整组,重放前逐条做幂等检查作为第二道保险,结束后再把检查点推到最后一条提交。LSN 从检查点与文件尾里较大的那个续号,跨重启单调,`SystemStatus` 应答里的 `last_lsn` 报告该检查点 LSN。建表、删表、建索引、删索引与数据改动走同一条 WAL,恢复时一并重放。
有整数 id 的表按行比对并提交差分;其他表按整表替换提交。发生冲突时返回 serialization_failure 并保留事务,由调用方回滚或重试。事务内不支持 DDL 或切换数据库。
堆页的空间回收分两级:删行只在槽位目录里把该槽的长度置零(墓碑),页内留下的空洞按四分之一阈值判定——死空间达到整页四分之一时随这次删除一起整理(同一次写页,不额外写 WAL),把活行重新码放到页尾并原地更新槽里的偏移。`compact` 请求在没有隐藏列的表上就是逐页做同一件事,只有带隐藏列(`DROP COLUMN` 留下的旧字节)时才整表重写。整理只改页内偏移、不动槽位号,所以索引里存的「页号加槽号」位置继续有效,不需要重建索引;跨页的空洞不做填充,行仍按插入顺序进页,没有 `ORDER BY` 时的可见顺序不变。
## WAL 与崩溃恢复
系统数据库及部分管理操作仅允许管理员访问。系统表统一用 `__system_` 前缀:账号表 `__system_users` 对语句通道隐藏,角色、授权、带 option 的授权、成员四张表(`__system_roles` / `__system_grants` / `__system_grant_options` / `__system_members`)只在 `system` 库里可见,权限服务自身也通过 SQL 读写它们;存储启动时把旧名 `sys_roles` / `sys_grants` / `sys_members` 连同数据与索引文件一起迁移过来。角色支持互相继承:`GRANT 角色 A TO 角色 B` 记一条 B 属于 A 的成员关系,判定权限时沿成员关系算出向上闭包再取并集;写入时拒绝成环与自继承,删角色会清掉它两个方向的成员关系。带 `WITH GRANT OPTION` 的授权记在第四张表 `__system_grant_options` 里(同样的角色、权限、对象三列,另开一张表省掉给 `__system_grants` 加列带来的系统表结构迁移):普通身份只要手里那条授权带 grant option 就能把同一项权限转授给别的角色,`REVOKE` 仍然只允许管理员,收回授权时连它的 grant option 一起收掉,但不级联收回别人已经转授出去的授权。授权命令由权限服务在会话层之外直接执行,`GRANT` 命令会先按当前库把对象补全成 `库.表` 再落盘,因此判断转授权资格与实际写入用的是同一个对象名。
1. 一组写操作与提交标记写入 WAL,每条记录带递增 LSN。
2. 数据和 catalog 刷盘后,检查点推进到已完成提交的 LSN。
3. 按检查点截断日志前缀,保留尚未确认刷盘的记录。
4. 重启时只重放检查点之后的已提交组,丢弃未提交组,并进行幂等检查。
账号与权限改动会断开受影响账号的在用连接。TCP 服务维护一张在线连接表(自增编号对应一条连接条目:登录账号与「已被断开」标记),会话在执行完 `CREATE` / `ALTER` / `DROP USER` 以及 `CREATE` / `DROP ROLE`、`GRANT` / `REVOKE` 之后算出受影响账号——角色类命令先沿成员关系把角色展开成具体账号名(删角色必须在命令落地前算,落地后成员关系就没了)——再给这些账号的连接打上断开标记。连接线程读到请求后先看标记,命中就回 `unauthorized`(`session was dropped by an account change`)并收工,因此空闲连接在客户端下一次请求时结束,不强行从别的线程中断阻塞读取:Windows 上 GHC 经 `socketToHandle` 得到的句柄,`hGetLine` / `hIsEOF` 阻塞期间既收不到 `throwTo` 也不受 `timeout` 约束,标记法不依赖异步异常,全平台行为一致。权限判定本身不缓存、每条语句现查,所以这里补的是「已登录的长连接不能继续用旧身份」,与不相关账号的连接无关。Web 侧走令牌,令牌登录时记下账号版本,改口令或删账号会递增版本,旧令牌因此自动失效。
建表、删表、建索引、删索引和数据修改均进入 WAL。LSN 跨重启续号,SystemStatus 的 last_lsn 表示检查点 LSN。
服务监听地址、端口和请求限制可在 `chusql.toml` 的 `[server]` 分区中配置。
若系统目录损坏导致服务无法启动,使用离线 bootstrap。repair、recover 与 reset 的范围及操作步骤见 [bootstrap.md](bootstrap.md)。
## 页面与空间回收
- 删除行先将槽位标记为墓碑。
- 死空间达到页大小的四分之一时,随删除整理页内活行。
- 整理保留槽位编号,索引中的页号与槽号仍有效。
- compact 通常逐页整理;存在 DROP COLUMN 留下的隐藏列时整表重写。
不进行跨页填洞;没有 ORDER BY 时仍保留当前扫描顺序,但应用应使用显式排序获得稳定结果。
## 身份、授权与系统目录
系统表统一使用 __system_ 前缀,身份与凭据目录对普通语句隐藏。SUPERUSER 拥有最高权限,system_catalog_manager 提供受限目录管理能力;两者不通过角色成员关系继承。
| 机制 | 行为 |
| --- | --- |
| 角色继承 | 沿成员关系取得权限并集;拒绝自继承与环 |
| 表级授权 | 支持 SELECT、INSERT、UPDATE、DELETE 和 ALL |
| 转授权 | WITH GRANT OPTION 允许将持有的权限授予其他身份 |
| 撤销 | 管理员撤销权限及对应转授权能力;当前不级联撤销已转授权限 |
| 对象名称 | 授权前补全为库.表,检查与持久化使用同一名称 |
类型目录说明见 [types.md](types.md),身份字段与权限边界见 [users-and-roles.md](users-and-roles.md)。
## 权限变更与会话失效
账号、属性和授权变化会标记受影响的在线连接。角色变化先展开成员关系,确定涉及的登录身份。
TCP 连接在下一次请求时返回 unauthorized 并关闭;空闲连接不被强行中断。权限逐条查询,避免继续使用旧授权。Web 会话还通过账号版本校验,口令或身份变更使旧凭据失效。
+7
View File
@@ -0,0 +1,7 @@
# README 徽章
所有徽章都是本地 SVG,采用统一的深色标签、彩色值区和圆角样式。
执行 `pwsh -File scripts/update-code-lines.ps1` 可重新生成全部徽章。代码行数包含代码、测试、注释和空行,排除 Git 忽略的构建工件;行数与提交编号是生成时的本地快照。
发布徽章链接 GitHub 版本列表。构建、测试和门禁徽章提供实时状态页面入口,本地图片显示 `view status` 或 `manual status`,不保存或宣称实时通过状态。门禁由所有者手动触发。
+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="160" height="24" viewBox="0 0 160 24" role="img" aria-label="build: view status">
<title>Open the build workflow for live status; this local badge is a navigation link.</title>
<defs><clipPath id="rounded"><rect width="160" height="24" rx="5"/></clipPath></defs>
<g clip-path="url(#rounded)"><rect width="160" height="24" fill="#1e293b"/><rect x="59" width="101" height="24" fill="#0369a1"/></g>
<g fill="#fff" text-anchor="middle" font-family="Verdana,DejaVu Sans,sans-serif" font-size="11"><text x="29.5" y="16">build</text><text x="109.5" y="16">view status</text></g>
</svg>

After

Width:  |  Height:  |  Size: 634 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="139" height="24" viewBox="0 0 139 24" role="img" aria-label="commit: fd5f320">
<title>Local HEAD snapshot when the badges were generated; open commit history for latest updates.</title>
<defs><clipPath id="rounded"><rect width="139" height="24" rx="5"/></clipPath></defs>
<g clip-path="url(#rounded)"><rect width="139" height="24" fill="#1e293b"/><rect x="66" width="73" height="24" fill="#475569"/></g>
<g fill="#fff" text-anchor="middle" font-family="Verdana,DejaVu Sans,sans-serif" font-size="11"><text x="33" y="16">commit</text><text x="102.5" y="16">fd5f320</text></g>
</svg>

After

Width:  |  Height:  |  Size: 637 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="167" height="24" viewBox="0 0 167 24" role="img" aria-label="gate: manual status">
<title>Open the owner-triggered gate workflow for status; this local badge is a navigation link.</title>
<defs><clipPath id="rounded"><rect width="167" height="24" rx="5"/></clipPath></defs>
<g clip-path="url(#rounded)"><rect width="167" height="24" fill="#1e293b"/><rect x="52" width="115" height="24" fill="#b45309"/></g>
<g fill="#fff" text-anchor="middle" font-family="Verdana,DejaVu Sans,sans-serif" font-size="11"><text x="26" y="16">gate</text><text x="109.5" y="16">manual status</text></g>
</svg>

After

Width:  |  Height:  |  Size: 644 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="209" height="24" viewBox="0 0 209 24" role="img" aria-label="languages: Haskell + Rust">
<title>ChuSQL implementation languages: Haskell and Rust.</title>
<defs><clipPath id="rounded"><rect width="209" height="24" rx="5"/></clipPath></defs>
<g clip-path="url(#rounded)"><rect width="209" height="24" fill="#1e293b"/><rect x="87" width="122" height="24" fill="#6d28d9"/></g>
<g fill="#fff" text-anchor="middle" font-family="Verdana,DejaVu Sans,sans-serif" font-size="11"><text x="43.5" y="16">languages</text><text x="148" y="16">Haskell + Rust</text></g>
</svg>

After

Width:  |  Height:  |  Size: 617 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="174" height="24" viewBox="0 0 174 24" role="img" aria-label="source lines: 37,149">
<title>Physical source lines; includes tests, comments and blank lines.</title>
<defs><clipPath id="rounded"><rect width="174" height="24" rx="5"/></clipPath></defs>
<g clip-path="url(#rounded)"><rect width="174" height="24" fill="#1e293b"/><rect x="108" width="66" height="24" fill="#0369a1"/></g>
<g fill="#fff" text-anchor="middle" font-family="Verdana,DejaVu Sans,sans-serif" font-size="11"><text x="54" y="16">source lines</text><text x="141" y="16">37,149</text></g>
</svg>

After

Width:  |  Height:  |  Size: 619 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="139" height="24" viewBox="0 0 139 24" role="img" aria-label="release: browse">
<title>Open GitHub releases to view published versions.</title>
<defs><clipPath id="rounded"><rect width="139" height="24" rx="5"/></clipPath></defs>
<g clip-path="url(#rounded)"><rect width="139" height="24" fill="#1e293b"/><rect x="73" width="66" height="24" fill="#6d28d9"/></g>
<g fill="#fff" text-anchor="middle" font-family="Verdana,DejaVu Sans,sans-serif" font-size="11"><text x="36.5" y="16">release</text><text x="106" y="16">browse</text></g>
</svg>

After

Width:  |  Height:  |  Size: 594 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="160" height="24" viewBox="0 0 160 24" role="img" aria-label="tests: view status">
<title>Open the test workflow for live status; this local badge is a navigation link.</title>
<defs><clipPath id="rounded"><rect width="160" height="24" rx="5"/></clipPath></defs>
<g clip-path="url(#rounded)"><rect width="160" height="24" fill="#1e293b"/><rect x="59" width="101" height="24" fill="#0369a1"/></g>
<g fill="#fff" text-anchor="middle" font-family="Verdana,DejaVu Sans,sans-serif" font-size="11"><text x="29.5" y="16">tests</text><text x="109.5" y="16">view status</text></g>
</svg>

After

Width:  |  Height:  |  Size: 633 B

+41
View File
@@ -0,0 +1,41 @@
# Bootstrap 安装引导与离线修复
`csql-bootstrap` 初始化系统目录,也能在服务无法启动时离线修复。所有维护命令必须先停止使用同一数据目录的 server、Web、CLI 和其他进程。新版本使用数据目录锁拒绝并发维护;旧版本进程不具备该锁,仍须手动停止。
## 安装引导
```text
csql-bootstrap --config settings.toml --user root --password-stdin
```
口令从标准输入的一行读取,哈希后保存。程序创建系统目录、启用且可登录的 SUPERUSER,以及现有内置类型登记项。重复引导保留已有口令和身份属性,拒绝冲突的类型定义及已降权的引导身份。安装脚本调用此入口。只有明确指定 `--passwordless` 才选择免密初始化。
## 三种维护策略
| 命令 | 行为 | 数据影响 |
| --- | --- | --- |
| `repair` | 修复已知系统表的目录结构、列定义及结构元数据 | 不重放 WAL,不补初始化数据;原有 `.db` 文件必须保持字节一致 |
| `recover` | 修复结构、重放可用的已提交 WAL,补齐必要初始化内容 | 可能改变系统表数据;尽量保留现有身份、口令与授权 |
| `reset` | 重建初始系统目录,创建指定管理员及内置类型登记项 | 清除原系统目录中的身份、口令、角色关系、授权及其他系统内容 |
`recovery` 是 `recover` 的兼容别名。三个命令均保留业务数据库目录;`reset` 后业务数据仍在,但原访问授权已清除,需要重新创建身份并授权。
```text
csql-bootstrap repair --config settings.toml
csql-bootstrap recover --config settings.toml
csql-bootstrap reset --config settings.toml --user root --password-stdin
```
优先使用 `repair`。若必须恢复数据、补内置类型或初始化管理员,它会中止,保持原系统目录不变,并用英文提示使用 `recover` 或 `reset`。需要初始化缺失管理员时,`recover` 要求 `--password-stdin`。已有管理员的口令不会因提供该参数而重设。`reset` 要求提供口令输入选项或显式选择 `--passwordless`。
维护入口直接使用存储库,不依赖数据库服务能够启动,不提供 SQL 或 TCP 远程维护接口。运行时输出、错误和服务启动修复提示均使用英文。
## 副本、备份与恢复边界
程序在数据目录下的 `bootstrap-work-<时间戳>/system` 副本中执行并验证。验证失败时不替换原目录;候选副本保留供检查。成功时将原系统目录移为 `system-backup-<时间戳>`,再发布新目录。输出给出备份与候选路径。备份包含口令哈希和授权,应按数据库数据同等保护。
发布第二次目录移动失败时会尝试回滚。若发布期间进程或机器崩溃,先停止所有进程,检查输出及上述目录;保存候选与当前目录后,可将完整的备份恢复为数据目录下的 `system`。不要把不同版本或不同时间点的零散文件混合覆盖。
WAL 只能恢复尚且可用的已提交记录,不能保证恢复已截断、丢失或损坏的历史。已知缺失表的记录数无法恢复完整时会拒绝发布。损坏的数据页也可能使 `recover` 失败,此时需要完整备份或选择清除系统内容的 `reset`。
目录元数据严重损坏时,只能依据程序内置定义重建已知系统表。存在无法识别结构的系统表文件时拒绝重建,应从备份恢复或使用 `reset`。身份目录计数器损坏后只能依据现存身份编号重建,无法还原已删除身份的历史编号上限。业务数据库自身的结构、数据页和 WAL 不由这些命令修复。
+26 -89
View File
@@ -2,14 +2,31 @@
ChuSQL 的主要用户命令如下:
类型说明见 [自定义类型与预定义类型](types.md),身份属性说明见 [用户、角色与 root 属性](users-and-roles.md)。
SQL 的查询、事务、类型、用户与授权语法见 [syntax.md](syntax.md)。
## 组件命令
安装脚本在启动服务前运行 `csql-bootstrap`:创建系统目录、初始化默认 `root`(LOGIN / SUPERUSER / ENABLED),再登记内置类型。管理员名称可通过安装选项修改。初始化失败时安装报错,停止启动服务。
预装目录 `system.__system_types` 登记 18 个名称与别名:INT、INTEGER、BIGINT、SMALLINT、VARCHAR、CHAR、TEXT、STR、BOOLEAN、BOOL、FLOAT、REAL、DOUBLE、DECIMAL、NUMERIC、DATE、TIMESTAMP、BLOB;记录基础表示及是否接受类型参数。该目录为受保护的系统元数据,不创建预定义 DOMAIN;DOMAIN 仍按工作库保存。
重复引导保留已有身份、密码及一致的类型记录,补齐缺失类型;同名定义冲突明确报错。已有引导身份失去管理员属性时拒绝继续,不自动提升权限。
修复或补齐安装引导时,可停止数据库服务后运行 `csql-bootstrap --config FILE --user root --password-stdin`,通过标准输入提供口令。
系统目录故障时,先停止所有数据库进程,再依次考虑 `csql-bootstrap repair --config FILE`、`recover` 或清除系统身份和授权的 `reset`。`repair` 仅修复结构,涉及数据修改时中止;完整说明见 [bootstrap.md](bootstrap.md)。
| 命令 | 作用 |
| --- | --- |
| `chusql-server` | 启动数据库服务 |
| `chusql-web` | 启动 Web 管理端 |
| `csql` | 启动命令行客户端 |
| `csql-web` | 按配置启动 Web 管理端 |
| `csql-bootstrap` | 初始化系统身份与内置类型目录 |
各组件默认使用同一份 `chusql.toml`。需要临时使用其他配置时,可通过 `--config FILE` 指定。
各组件默认使用同一份 `settings.toml`。需要临时使用其他配置时,可通过 `--config FILE` 指定。
## csql
@@ -31,6 +48,8 @@ csql [options]
| `--config FILE` | 指定配置文件 |
| `-h, --help` | 显示帮助 |
CLI 历史只记录完整 SQL 和元命令。包含 `IDENTIFIED` 或 `PASSWORD`(忽略大小写)的输入不记录,多行口令输入和未完成语句也不会逐行写入历史;包含这些文本的普通字符串或标识符同样会被排除。已有历史文件不会自动清理。含这些文本的 SQL 解析错误会隐藏输入原文。
示例:
```sh
@@ -40,53 +59,8 @@ csql -f json -e "SELECT count(*) FROM orders"
在脚本里非交互使用(配合 `-e`):
- 口令从标准输入读取,行尾用 LF(`\n`)或者干脆不写行尾;CRLF 会把 `\r` 算进口令,结果是 `unauthorized`。
- `root's password: ` 这个提示写在标准输出里,解析输出时要先去掉它;错误信息写在标准错误,失败时退出码为 `1`。
表名与列名的写法:
- 选定数据库后语句里直接写表名即可,例如 `SELECT name FROM orders WHERE orders.id = 1`。
- 表名可以带库前缀(`sales.orders`),列名可以带表限定符(`orders.id`)或库限定符(`sales.orders.id`),这几种写法指向同一张表和同一个列。
- 只有候选唯一时才自动推导前缀;同名的表或列出现在多个候选上会以 `ambiguous table` / `ambiguous column` 报错,需要写全。
- `FROM orders AS o` 之后只能用 `o.id`,不能再写 `orders.id`。
查询写法:
- `SELECT` 支持 `WHERE`、`ORDER BY`、`LIMIT`、`JOIN` / `LEFT JOIN`、`GROUP BY` 与聚合,以及标量 / `IN` / `EXISTS` 子查询和量词比较(`op ANY` / `op ALL`)。
- 来源可以是派生表:`SELECT d.name FROM (SELECT name FROM users) d`。派生表必须带别名,内层不能引用外层列;输出列取裸列名(内层写 `u.id`,外层用 `d.id` 引用)。
- 投影列可以起别名:`SELECT count(*) AS total FROM orders`,别名就是结果集的列名。`ORDER BY` 仍按来源列解析,要引用派生表的列得写限定名(如 `ORDER BY d.total`)。
- 鉴权覆盖整棵来源树:查派生表时内层表也要有权限,只有外层引用的表有权限不够。
比较运算符:
- 支持 `=`、`<>`(也可写 `!=`)、`>`、`>=`、`<`、`<=`;`<>` 与 `!=` 是同一个运算符。
- `>`、`>=`、`<`、`<=` 在列上有索引时会改写成索引范围扫描;`>=` / `<=` 是闭区间,端点值本身也在结果里。
- 任一操作数为 `NULL` 时比较结果是 `NULL`,`WHERE` 只保留结果为真的行。
量词比较:
- `值 op ANY (SELECT 单列 …)` 表示子查询里至少有一行让比较成立;`值 op ALL (…)` 表示每一行都成立。
- `ANY` 的空子查询为假、`ALL` 的空子查询为真;子查询里出现 `NULL` 时按 `AND` / `OR` 的三值规则,条件可能为 `NULL`(`WHERE` 里等同不通过)。
- 取反写相反的运算符即可:`<> ALL` 与 `NOT IN` 同义,`<> ANY` 表示不存在相等的行。
公共表表达式:
- `WITH 名字 AS (SELECT …), 另一个 AS (SELECT …) SELECT … FROM 名字` 先定义再引用,名字可以带列清单(`WITH t(who) AS (SELECT name FROM users) …`)。
- `WITH` 在解析期就展开成派生表,所以引用处只能当普通表用:没有别名时用 CTE 名当限定名(`WITH t AS (…) SELECT t.name FROM t`)。
- 名字不分大小写;只能引用前面已经定义的 CTE,向前引用会当成普通表名去查(有同名表就用表,没有就报 `unknown table`)。
- 不支持递归 CTE,主语句与每个 CTE 都只能是 `SELECT`;同一个 CTE 被引用两次会各执行一次(不做物化)。
- 鉴权同样覆盖 CTE 里的表:`WITH d AS (SELECT name FROM orders) …` 要求对 `orders` 有权限。
事务:
- `BEGIN`(或 `START TRANSACTION`)开始显式事务,`COMMIT` 提交,`ROLLBACK` 回滚。
- `SAVEPOINT 名字` 在事务里记一个回滚点,`ROLLBACK TO 名字`(可写 `ROLLBACK TO SAVEPOINT 名字`)退回该点但保留它,`RELEASE 名字` 丢掉该点与它之后的回滚点。
- 事务内只允许数据语句;DDL 与 `USE` 会被拒绝,`SHOW DATABASES` 这类不碰表数据的语句照常可用。
- 事务里的读都看开事务那一刻的快照:别的连接在事务期间提交的改动不会出现在事务里,本会话未提交的改动别的连接也看不见。
- 同一连接同时只能有一个事务,没有嵌套事务;回滚点只在事务内有效,事务结束就没了。开事务前要先选好数据库。
- 提交时按行 `id` 写回本事务改动过的行,事务没碰过的行不受影响;没有整数 `id` 的表按整表替换提交。
- 提交前会比对基线:本事务改过的行如果已被别的连接改掉或删掉,提交报 `serialization_failure` 并保留事务,由调用方 `ROLLBACK` 或重试。
- 提交是持久的:整批改动与一条提交标记一次落盘,提交后进程被强杀也会在重启时重放;提交前崩溃则整批丢弃,不会只落一半。
- 口令从标准输入读取,兼容 LF、CRLF 和无行尾;保留口令中的空格。
- 管道输入时口令提示和错误信息写在标准错误,标准输出保留查询结果,失败时退出码为 `1`。
### 交互模式
@@ -105,43 +79,6 @@ SQL 语句以分号结束,可跨多行输入。
| `\f <格式>` | 修改输出格式 |
| `\dr` | 查看角色与授权信息(管理员) |
## 账号与权限
默认管理员账号为 `root`。普通账号创建后默认不拥有数据访问权限,需要管理员通过角色进行授权。
常用账号命令:
```sql
CREATE USER alice IDENTIFIED BY 'password';
ALTER USER alice IDENTIFIED BY 'new-password';
DROP USER alice;
```
常用角色和授权命令:
```sql
CREATE ROLE analyst;
CREATE ROLE reviewer;
GRANT SELECT ON orders TO analyst;
GRANT SELECT ON orders TO reviewer WITH GRANT OPTION;
GRANT analyst TO reviewer;
GRANT reviewer TO alice;
REVOKE SELECT ON orders FROM analyst;
REVOKE analyst FROM reviewer;
REVOKE reviewer FROM alice;
DROP ROLE analyst;
```
当前表级权限包括:`SELECT`、`INSERT`、`UPDATE`、`DELETE` 和 `ALL`。
角色可以互相继承:`GRANT analyst TO reviewer` 让 `reviewer` 拿到 `analyst` 的授权,并把 `analyst` 记为 `reviewer` 的成员;继承会沿成员关系传递,成环的授权被拒绝(报 `conflict`),自继承同样拒绝。删角色时它的两向成员关系一起清掉。被授权者是不是角色,看名字是否在 `__system_roles` 里;用户名与角色名同名时按角色算。
管理员拥有完整权限;数据库、表、索引等结构管理操作仅允许管理员执行。
`GRANT ... WITH GRANT OPTION` 让拿到的授权可以再转授:普通身份只有手里那条授权带 grant option 时才能执行 `GRANT`,报错是 `grant option required: <权限> ON <表>`;`REVOKE` 仍然只允许管理员,收回授权会连它的 grant option 一起收掉,且不会级联收回别人转授出去的授权。转授权随角色继承一起传递。
系统数据库 `system` 仅允许管理员访问。
## chusql-web
基本用法:
@@ -154,14 +91,13 @@ chusql-web [options]
| 选项 | 说明 |
| --- | --- |
| `--host H` | 监听地址 |
| `--port N` | 监听端口 |
| `--static DIR` | 静态资源目录 |
| `--listen-host H` | 监听地址 |
| `--user NAME` | 管理员账号名 |
| `--config FILE` | 指定配置文件 |
| `-h, --help` | 显示帮助 |
其余请求大小、会话、登录限制、分页等参数通常通过 `chusql.toml` 配置即可。
静态资源目录固定为 `static`。其余请求大小、会话、登录限制、分页等参数通常通过 `settings.toml` 配置即可。
## chusql-server
@@ -175,8 +111,9 @@ chusql-server [options]
| 选项 | 说明 |
| --- | --- |
| `--host H` | 数据库服务监听地址 |
| `--port N` | 数据库服务监听端口 |
| `--listen-host H` | 数据库服务监听地址 |
| `--user NAME` | 管理员账号名 |
| `--max-message N` | 单条请求大小上限 |
| `--max-rows N` | 单次查询返回行数上限 |
| `--config FILE` | 指定配置文件 |
+14 -9
View File
@@ -1,11 +1,11 @@
# 配置选项
ChuSQL 使用统一配置文件 `chusql.toml`。默认位置:
ChuSQL 使用统一配置文件 `settings.toml`。默认位置:
| 平台 | 路径 |
| --- | --- |
| Windows | `%APPDATA%\ChuSQL\chusql.toml` |
| Linux / macOS | `$XDG_CONFIG_HOME/ChuSQL/chusql.toml`;未设置时为 `~/.config/ChuSQL/chusql.toml` |
| Windows | `%APPDATA%\ChuSQL\settings.toml` |
| Linux / macOS | `$XDG_CONFIG_HOME/ChuSQL/settings.toml`;未设置时为 `~/.config/ChuSQL/settings.toml` |
`chusql-server`、`chusql-web` 和 `csql` 均可通过 `--config <path>` 临时指定其他配置文件。配置优先级为:**命令行参数 > 配置文件 > 内置默认值**。
@@ -17,18 +17,17 @@ ChuSQL 使用统一配置文件 `chusql.toml`。默认位置:
| `[btree] order` | `4` | B+ 树阶数 |
| `[buffer] pool_size` | `1024` | 缓冲池页数 |
| `[storage] data_dir` | 平台默认数据目录 | 数据文件保存位置 |
| `[storage] log_files` | 安装时填写平台日志目录 | 诊断日志目录,不是 WAL |
| `[log] level` | `info` | 日志级别 |
数据目录仅由 `chusql-server` 访问。生产环境建议使用绝对路径。
正常运行时数据目录由 `chusql-server` 访问,离线初始化与修复由 `csql-bootstrap` 访问。安装器填写绝对路径,日志目录由启动器与安装脚本使用。
## Web 配置
| 配置项 | 默认值 | 说明 |
| --- | --- | --- |
| `host` | `127.0.0.1` | Web 服务监听地址 |
| `port` | `7778` | Web 服务端口 |
| `static_dir` | `static` | 静态资源目录 |
| `user` | `root` | 管理员账号名 |
| `listen_host` | `127.0.0.1` | Web 服务监听地址 |
| `cookie_secure` | `false` | HTTPS 部署时应启用 |
| `body_limit` | `65536` | 请求体大小上限(字节) |
| `session_idle` | `28800` | 会话空闲超时(秒) |
@@ -43,12 +42,14 @@ ChuSQL 使用统一配置文件 `chusql.toml`。默认位置:
| `password_min_length` | `12` | 普通账号最短口令长度 |
| `password_classes` | `2` | 口令至少包含的字符类别数 |
监听地址见上表,静态资源目录固定为 `static`(相对安装目录),管理员账号名不在配置里、由启动参数 `--user` 指定(默认 `root`);后两项不能用配置项改写。
## Server 配置
| 配置项 | 默认值 | 说明 |
| --- | --- | --- |
| `host` | `127.0.0.1` | 数据库服务监听地址 |
| `port` | `7777` | 数据库服务端口 |
| `listen_host` | `127.0.0.1` | 数据库服务监听地址 |
| `max_message` | `1048576` | 单条请求最大大小(字节) |
| `max_rows` | `1000` | 单次查询最大返回行数 |
@@ -70,7 +71,11 @@ ChuSQL 使用统一配置文件 `chusql.toml`。默认位置:
配置可通过以下方式修改:
1. 直接编辑 `chusql.toml`;
1. 直接编辑 `settings.toml`;
2. 通过 Web 设置页面修改。
大多数配置在相关服务下次启动时生效。
安装器选择 `resources/settings.toml.linux` 或 `resources/settings.toml.windows`,填入当前用户的绝对路径后释放为 `settings.toml`。两份模板逐项提供英文注释。
Linux 数据目录为 `${XDG_DATA_HOME:-$HOME/.local/share}/chusql/data`,日志目录为 `${XDG_STATE_HOME:-$HOME/.local/state}/chusql/logs`。Windows 数据和日志分别为 `%LOCALAPPDATA%\ChuSQL\data` 与 `%LOCALAPPDATA%\ChuSQL\logs`。安装参数 `--data-dir` / `-DataDir` 可覆盖数据目录,指定安装目录不会改变默认数据位置。
+1 -1
View File
@@ -80,7 +80,7 @@ Windows:
- `csql`:命令行客户端;
- `chusql-web`:Web 管理端;
- `csql-bootstrap`:系统目录初始化工具;
- `chusql.toml`:全局配置文件;
- `settings.toml`:全局配置文件;
- 数据目录与日志目录。
常用命令:
+134
View File
@@ -0,0 +1,134 @@
# 性能基准
## 测试方法与复现
`benchmark/` 中的应用名为 `benchmark4csql`。它启动真实 `chusql-server`,通过本机 TCP 使用共享客户端协议,服务端调用 Rust 存储;不以纯内存引擎测量代替端到端性能。每次运行创建独立临时数据库,结束后停止服务并清理数据。任一 SQL、结果校验或并发请求失败会使运行失败。
在 `benchmark/` 中构建优化版本,并在 PATH 中放置 release 存储动态库:
```powershell
$env:TMP = 'D:\Workspace\ChuSQL\logs\cli-test-tmp'
$env:TEMP = $env:TMP
$env:PATH = 'D:\Workspace\ChuSQL\chusql-core\storage\target\release;' + $env:PATH
$env:GHC_CHARENC = 'UTF-8'
stack build chusql-core-model chusql-core-engine chusql-interface chusql-server chusql-bootstrap benchmark4csql --force-dirty --ghc-options=-O2
stack exec -- benchmark4csql 10000 50000
```
首次运行前从 `chusql-core/storage/` 执行 `cargo build --release`,并创建上面的临时目录。Linux 使用 `LD_LIBRARY_PATH` 指向 release 存储目录。两个参数分别为用户数、订单数,默认 10,000 / 50,000,均需至少 100。
读取场景先预热一次,再采样七次,记录均值、p50、p95、最大延迟与请求数/秒。p95 使用最近秩;七次采样的 p95 等于最大值,只适合比较本次场景,不能作为稳定的生产尾延迟。并发场景使用独立的已认证 TCP 会话,1、4、8 个客户端各执行 100 次点查,同步开始;吞吐以整个并发阶段的墙上耗时计算,连接与登录不计入该阶段。
加载主表每条 INSERT 包含 500 行。另测逐条持久化提交、200 条 INSERT 的单次事务提交、更新、删除和事务回滚。写入保留正常 WAL、数据刷盘与检查点路径,没有关闭持久化。查询耗时包含 TCP、SQL 解析、语义检查、引擎执行与响应解码;不包含 UI 渲染。读测试主要校验结果行数,加载 COUNT、并发点查值和回滚后值另行校验。
## 实测环境
- 测量日期:2026-10-05,Asia/Shanghai。
- CPU:AMD Ryzen 7 7840H,8 核 / 16 逻辑处理器。
- 可见物理内存:31.2 GiB。
- 系统:Windows 11 专业版,10.0.26200。
- 数据目录:D 盘 NTFS 上的仓库 `logs/` 临时子目录;未测存储设备裸吞吐。
- 编译:GHC 9.10.3,Haskell 本地包 `-O2`;Rust release 动态库。
- 规模:10,000 用户、50,000 订单、5,000 条 512 字节字符串宽行;订单按用户循环分配,年龄按 100 个值循环分布。
## 性能测试结果
完整运行返回退出码 0,输出 `PRESSURE_OK all result checks passed` 和 `BENCHMARK_OK all scenarios completed`。原始日志保存于本机 `logs/benchmark-performance.log`。下面保留该次成功运行的数据;首次失败运行不参与统计。
启动、引导、认证及创建基准库合计 0.62 秒。纯 TCP Ping 200 次平均 0.100 ms;schema catalog 200 次平均 0.267 ms。主键点查另外连续执行 100 次,平均 0.300 ms,100/100 命中。
### 读取场景
每项七个测量样本,单位为毫秒。请求吞吐由总采样耗时计算,为串行吞吐。
| 场景 | 返回行数 | 均值 | p50 | p95 / 最大值 | 请求/秒 |
| --------------------------------------- | -------: | --------: | --------: | -----------: | -------: |
| 用户全表扫描 | 10,000 | 23.768 | 18.961 | 36.309 | 42.07 |
| 订单全表扫描 | 50,000 | 114.094 | 118.676 | 131.074 | 8.76 |
| 主键点查 | 1 | 0.415 | 0.278 | 0.742 | 2,408.23 |
| name 点查,无二级索引 | 1 | 21.776 | 21.908 | 23.257 | 45.92 |
| name 点查,有二级索引 | 1 | 0.338 | 0.312 | 0.466 | 2,961.71 |
| 主键闭区间范围,100 行 | 100 | 0.675 | 0.644 | 0.985 | 1,482.33 |
| 按年龄排序,LIMIT 100 | 100 | 19.361 | 20.748 | 21.720 | 51.65 |
| 100 个年龄值分组计数 | 100 | 18.139 | 17.843 | 22.200 | 55.13 |
| 用户与订单过滤连接 | 4,500 | 144.929 | 142.598 | 158.723 | 6.90 |
| 过滤派生表与订单连接 | 4,500 | 141.797 | 141.535 | 148.438 | 7.05 |
| CTE 后按年龄分组 | 100 | 28.539 | 29.146 | 30.921 | 35.04 |
| 无关联 COUNT 标量子查询,外层 LIMIT 100 | 100 | 30.777 | 29.844 | 34.692 | 32.49 |
| EXISTS,20 个不同外层绑定 | 20 | 2,061.770 | 2,070.403 | 2,123.953 | 0.49 |
| 512 字节字符串宽行全表扫描 | 5,000 | 22.969 | 22.791 | 24.485 | 43.54 |
相关 EXISTS 用派生表先限定外层为前 20 个用户,每次子查询按 `orders.user_id = u.id` 匹配;`orders.user_id` 没有二级索引。每个绑定具有五条订单,外层与内层都读取真实存储。
### 并发点查
每客户端 100 次主键点查。单请求延迟合并所有客户端样本计算;总吞吐用并发阶段墙上时间计算,不能用单请求延迟均值的倒数替代。
| 客户端 | 请求数 | 墙上耗时 ms | 总吞吐 请求/秒 | 单请求均值 ms | p50 ms | p95 ms | 最大 ms |
| -----: | -----: | ----------: | -------------: | ------------: | -----: | -----: | ------: |
| 1 | 100 | 29.094 | 3,437.10 | 0.290 | 0.250 | 0.544 | 1.183 |
| 4 | 400 | 37.862 | 10,564.79 | 0.374 | 0.335 | 0.574 | 1.796 |
| 8 | 800 | 48.877 | 16,367.72 | 0.481 | 0.437 | 0.735 | 2.027 |
### 写入、索引及事务
这些写入阶段各测量一次,不报告分位数。场景按顺序执行,数据库大小、已打开表数量和索引状态会变化。
| 场景 | 处理行数 | 总耗时 ms | 行/秒 |
| ---------------------------------------------- | -------: | ---------: | --------: |
| 用户加载,500 行/提交 | 10,000 | 762.419 | 13,116.15 |
| 订单加载,500 行/提交 | 50,000 | 3,915.697 | 12,769.12 |
| 512 字节宽行加载,500 行/提交 | 5,000 | 1,084.740 | 4,609.40 |
| writes 逐条持久化 INSERT | 200 | 1,119.0 | 178.73 |
| writes 事务中 200 条 INSERT,含 BEGIN / COMMIT | 200 | 67,549.539 | 2.96 |
| batch_t 加载,100 行/提交 | 10,000 | 1,309.4 | 7,637.09 |
| items 加载,100 行/提交 | 50,000 | 6,336.8 | 7,890.42 |
其他单次耗时:
| 操作 | 耗时 ms |
| -------------------------------- | ------: |
| 10,000 行 users 建 name 索引 | 239.7 |
| UPDATE writes 的 400 行 | 16.4 |
| 事务内 UPDATE writes 的 400 行 | 0.5 |
| ROLLBACK 上述修改 | 0.3 |
| DELETE writes 的 200 行 | 10.3 |
| DELETE users 单行 | 48.1 |
| DELETE users 全部剩余行 | 765.7 |
| 50,000 行 items 无索引 code 点查 | 135.2 |
| 50,000 行 items 建 code 索引 | 1,089.8 |
| 建索引后 code 点查 | 0.4 |
回滚后校验 `writes.id = 1` 的 age 仍为 2,事务中写入的 0 没有落入正式数据。删除后 writes 行数为 200。加载、连接及并发点查的结果校验全部通过。
## 性能瓶颈分析
### 1. 事务提交的整库差分是首要排查点
本次 200 条事务 INSERT 比 200 次独立持久化 INSERT 慢约 60 倍。事务阶段开始时,数据库已有 10,000 用户、50,000 订单及 5,000 宽行,事务只修改 writes 表。事务内一次 400 行 UPDATE 仅耗时 0.5 ms,说明不能将整个事务阶段的 67.55 秒简单归因于 SQL 更新计算或 fsync。
代码依据:服务端 [Session.hs](../chusql-server/src/ChuSQL/Server/Session.hs) 的 BEGIN 和 COMMIT 都获取整库快照。`transactionDiffs` 对具有整数 id 的表先走 keyedDiff,即使 staged 与 base 完全相同,也会建立关联列表并对每行执行线性 `lookup` / `any`;50,000 行未修改表仍可能进行平方级比较。COMMIT 分别调用冲突检查和操作生成,可能重复计算差分。这是与实测异常最吻合的瓶颈候选。
当前没有 BEGIN、逐条语句、COMMIT 的独立计时或 CPU profile,不能给各部分分摊比例。下一步应先拆分计时,再对未修改表快速跳过、以 Map / HashMap 做按 id 差分、复用一次差分计算结果,并考虑记录实际修改表与键。整库快照及内存统计重算也需要测量。不能据当前实现假设把 Web 编辑放进一个大事务就会更快。
### 2. 不同绑定的相关子查询仍重复扫描
20 个 EXISTS 绑定平均 2.06 秒,而单次 50,000 行订单扫描约 114 ms;两者量级相符。缓存只能合并相同绑定,这里的 20 个用户 id 都不同,因此仍需多次读取订单。执行路径 [Eval.hs](../chusql-core/engine/src/ChuSQL/Core/Engine/Algebra/Eval.hs) 将子查询逐绑定执行,没有半连接,EXISTS 也先得到子查询结果再判断是否为空。
优先验证 `orders.user_id` 二级索引及基于统计的点查选择,再评估半连接、EXISTS 找到首行即可停止、或单次物化内层匹配键。这个推断有查询结构和耗时量级支持,但本次没有对索引版相关子查询做对照,不把预期收益当作已测结果。
### 3. 扫描、结果传输与全量排序限制大结果查询
50,000 行扫描比 10,000 行扫描慢约 4.8 倍。无索引 name 点查只返回一行,耗时仍接近用户全表扫描;索引后均值下降约 64 倍,说明扫描工作量具有决定性影响。宽行加载吞吐降至约 4,609 行/秒,低于短行的约 1.3 万行/秒。
`sortRows` 使用完整 `sortBy` 后再 LIMIT,返回 100 行并不代表只处理 100 行。当前全表结果会经过存储解码、引擎求值及 TCP JSON 编码。下一步可测 Top-K 排序、分页或流式返回,以及存储、引擎、编码各阶段的时间和分配量。本次没有冷缓存、磁盘裸吞吐、分阶段 CPU 或内存峰值测量,无法把扫描耗时全部归到磁盘。
### 4. 批量提交有效,读并发扩展低于线性
批量加载达到约 1.3 万行/秒,200 次独立提交只有约 179 行/秒。数据和已打开表数量不同,这不是严格的单变量对照;但与 WAL 提交、表刷盘和检查点固定成本被摊薄的执行路径一致。Rust 的 `group_via_wal` 还会刷所有已打开表,应单独测量并评估只刷本组实际修改表的收益及恢复正确性。
8 客户端点查总吞吐约为 1 客户端的 4.8 倍,单请求均值从 0.290 ms 上升至 0.481 ms。表明只读请求有并发收益,也存在共享资源、调度或协议开销。本次没有混合读写或并发写压测,不据此推断写扩展能力;当前每库写独占的限制仍在。
## 使用范围
这是单机回环、主要为热缓存、管理员身份的单次基准,不是生产容量承诺。未覆盖远程网络、鉴权角色开销、崩溃恢复耗时、并发写冲突或大型宽表。七次读采样和单次写测量不足以建立统计置信区间,后续优化应使用相同参数、多次独立运行和分阶段 profile 比较。
+155
View File
@@ -0,0 +1,155 @@
# SQL 语法
客户端选项与元命令见 [commands.md](commands.md),类型与身份字段分别见 [types.md](types.md) 和 [users-and-roles.md](users-and-roles.md)。
## 查询、名称与事务
### 表名与列名
- 表级 DDL 可使用当前库限定名,例如选定 `sales` 后执行 `CREATE TABLE sales.orders (id INT)`;必须先选库,跨库 DDL 会被拒绝。
- 选定数据库后语句里直接写表名即可,例如 `SELECT name FROM orders WHERE orders.id = 1`。
- 表名可以带库前缀(`sales.orders`),列名可以带表限定符(`orders.id`)或库限定符(`sales.orders.id`),这几种写法指向同一张表和同一个列。
- 只有候选唯一时才自动推导前缀;同名的表或列出现在多个候选上会以 `ambiguous table` / `ambiguous column` 报错,需要写全。
- `FROM orders AS o` 之后只能用 `o.id`,不能再写 `orders.id`。
### SELECT 与派生表
- `SELECT` 支持 `WHERE`、`ORDER BY`、`LIMIT`、`JOIN` / `LEFT JOIN`、`GROUP BY` 与聚合,以及标量 / `IN` / `EXISTS` 子查询和量词比较(`op ANY` / `op ALL`)。
- 来源可以是派生表:`SELECT d.name FROM (SELECT name FROM users) d`。派生表必须带别名,内层不能引用外层列;输出列取裸列名(内层写 `u.id`,外层用 `d.id` 引用)。
- 投影列可以起别名:`SELECT count(*) AS total FROM orders ORDER BY total`,别名就是结果集的列名。`ORDER BY` 优先使用唯一的投影别名,再查来源列;重复的排序别名报歧义错误,来源列可以用限定名消除同名冲突。排序暂不接受任意表达式或列序号。
- 鉴权覆盖整棵来源树:查派生表时内层表也要有权限,只有外层引用的表有权限不够。
### 比较运算符
- 支持 `=`、`<>`(也可写 `!=`)、`>`、`>=`、`<`、`<=`;`<>` 与 `!=` 是同一个运算符。
- `>`、`>=`、`<`、`<=` 在列上有索引时会改写成索引范围扫描;`>=` / `<=` 是闭区间,端点值本身也在结果里。
- 任一操作数为 `NULL` 时比较结果是 `NULL`,`WHERE` 只保留结果为真的行。
### ANY / ALL
- `值 op ANY (SELECT 单列 …)` 表示子查询里至少有一行让比较成立;`值 op ALL (…)` 表示每一行都成立。
- `ANY` 的空子查询为假、`ALL` 的空子查询为真;子查询里出现 `NULL` 时按 `AND` / `OR` 的三值规则,条件可能为 `NULL`(`WHERE` 里等同不通过)。
- 量词与运算符组合示例:`<> ALL` 与 `NOT IN` 同义,`<> ANY` 表示至少存在一个不相等的值。
### WITH / CTE
- `WITH 名字 AS (SELECT …), 另一个 AS (SELECT …) SELECT … FROM 名字` 先定义再引用,名字可以带列清单(`WITH t(who) AS (SELECT name FROM users) …`)。
- `WITH` 在解析期就展开成派生表,所以引用处只能当普通表用:没有别名时用 CTE 名当限定名(`WITH t AS (…) SELECT t.name FROM t`)。
- 名字不分大小写;只能引用前面已经定义的 CTE,向前引用会当成普通表名去查(有同名表就用表,没有就报 `unknown table`)。
- 不支持递归 CTE,主语句与每个 CTE 都只能是 `SELECT`;同一个 CTE 被引用两次会各执行一次(不做物化)。
- 鉴权同样覆盖 CTE 里的表:`WITH d AS (SELECT name FROM orders) …` 要求对 `orders` 有权限。
### 事务与保存点
- `BEGIN`(或 `START TRANSACTION`)开始显式事务,`COMMIT` 提交,`ROLLBACK` 回滚。
- `SAVEPOINT 名字` 在事务里记一个回滚点,`ROLLBACK TO 名字`(可写 `ROLLBACK TO SAVEPOINT 名字`)退回该点但保留它,`RELEASE 名字` 丢掉该点与它之后的回滚点。
- 事务内只允许数据语句;DDL 与 `USE` 会被拒绝,`SHOW DATABASES` 这类不碰表数据的语句照常可用。
- 事务里的读都看开事务那一刻的快照:别的连接在事务期间提交的改动不会出现在事务里,本会话未提交的改动别的连接也看不见。
- 同一连接同时只能有一个事务,没有嵌套事务;回滚点只在事务内有效,事务结束就没了。开事务前要先选好数据库。
- 提交时按行 `id` 写回本事务改动过的行,事务没碰过的行不受影响;没有整数 `id` 的表按整表替换提交。
- 提交前会比对基线:本事务改过的行如果已被别的连接改掉或删掉,提交报 `serialization_failure` 并保留事务,由调用方 `ROLLBACK` 或重试。
- 提交是持久的:整批改动与一条提交标记一次落盘,提交后进程被强杀也会在重启时重放;提交前崩溃则整批丢弃,不会只落一半。
## 自定义类型 DOMAIN
由具有 `SUPERUSER` 属性的管理员选定工作库后执行:
```sql
USE sales;
CREATE DOMAIN short_label AS VARCHAR(32);
CREATE DOMAIN amount AS DECIMAL(10, 2);
CREATE TABLE products (id INT, label short_label, price amount);
INSERT INTO products (id, label, price) VALUES (1, 'book', 19.95);
SHOW DOMAINS;
```
`sales` 需事先存在。`short_label` 复用 `VARCHAR(32)` 的长度检查;`amount` 复用 `DECIMAL(10, 2)` 的数值处理。列保留 DOMAIN 名称,但赋值、比较、运算仍使用基础类型的语义。
已有表也可在 `ALTER TABLE ... ADD COLUMN` 或 `ALTER TABLE ... ALTER COLUMN ... TYPE` 中使用 DOMAIN。`SHOW DOMAINS` 返回当前库的 `domain`(名称)和 `base_type`(基础类型表示)。
名称不分大小写,长度为 1–48 个字符,使用 ASCII 字母、数字、下划线,并遵守 SQL 标识符的首字符规则。类型按工作库隔离:另一个库不会自动获得 `sales` 中的定义。类型名不得与已有类型冲突。
删除前必须解除所有列引用,例如:
```sql
DROP TABLE products;
DROP DOMAIN short_label;
DROP DOMAIN amount;
```
DOMAIN 可以基于内置基础类型或当前库已有的 DOMAIN 创建,例如:
```sql
CREATE DOMAIN short_text AS VARCHAR(32);
CREATE DOMAIN product_label AS short_text;
CREATE TABLE labels (label product_label);
```
`product_label` 保留对 `short_text` 的引用,最终仍按 `VARCHAR(32)` 检查。`SHOW DOMAINS` 中它的基础表示为 `domain(short_text,varchar(32))`。同样可以多层复用;被表列或其他 DOMAIN 引用的类型不能删除,需先删除引用者,再从外层到内层删除类型。不存在的基础 DOMAIN、自引用及循环定义会被拒绝。
当前不支持 DOMAIN 自身的 `CHECK`、`NOT NULL`、`DEFAULT`;这些约束仍可定义在表列上。创建和删除 DOMAIN 不能在显式事务内执行。
## 账号与权限
默认管理员账号为 `root`。普通账号创建后默认不拥有数据访问权限,需要管理员通过角色进行授权。
常用账号命令:
```sql
CREATE USER alice IDENTIFIED BY 'password';
ALTER USER alice IDENTIFIED BY 'new-password';
DROP USER alice;
```
用户和角色共用名称空间。`CREATE USER` 默认可以登录,`CREATE ROLE` 默认不能登录;两者默认启用,且不具有最高权限。管理员可以修改身份属性:
```sql
ALTER ROLE analyst LOGIN;
ALTER ROLE analyst IDENTIFIED BY 'analyst-password-1';
ALTER USER alice NOLOGIN;
ALTER USER alice DISABLED;
ALTER USER alice ENABLED LOGIN;
ALTER ROLE analyst SUPERUSER;
ALTER ROLE analyst NOSUPERUSER;
SHOW ROLES;
```
`SHOW ROLES` 展示全部身份的编号、名称、登录能力、最高权限标志、启用状态及创建时间,不返回口令。`ALTER USER` 与 `ALTER ROLE` 的属性和口令修改语法通用;同一属性不能重复指定。身份管理仅允许最高权限身份执行。
`SUPERUSER` 不依赖启动参数里的管理员名称(`--user`),也不会通过角色成员关系继承。禁用登录身份会拒绝登录;禁用权限角色会停止贡献继承权限。属性修改会使受影响的既有连接失效,下一次请求返回 `unauthorized` 后关闭。最后一个启用且可以登录的最高权限身份不能删除、禁用、取消登录或取消最高权限。
服务启动时迁移旧账号及角色,保留账号编号、口令、时间和原有授权关系。旧用户名与角色名重名时明确拒绝迁移,不合并身份。删除身份会同时清理其授权和两向成员关系;重建同名身份会分配新编号。
常用角色和授权命令:
```sql
CREATE ROLE analyst;
CREATE ROLE reviewer;
GRANT SELECT ON orders TO analyst;
GRANT SELECT ON orders TO reviewer WITH GRANT OPTION;
GRANT analyst TO reviewer;
GRANT reviewer TO alice;
REVOKE SELECT ON orders FROM analyst;
REVOKE analyst FROM reviewer;
REVOKE reviewer FROM alice;
DROP ROLE analyst;
```
当前表级权限包括:`SELECT`、`INSERT`、`UPDATE`、`DELETE` 和 `ALL`。
角色可以互相继承:`GRANT analyst TO reviewer` 让 `reviewer` 成为 `analyst` 的成员并取得其授权;继承会沿成员关系传递,成环的授权被拒绝(报 `conflict`),自继承同样拒绝。用户也可以直接接受表级授权或作为其他角色的成员。
管理员拥有完整权限;数据库、表、索引等结构管理操作仅允许管理员执行。
`GRANT ... WITH GRANT OPTION` 让拿到的授权可以再转授:普通身份只有手里那条授权带 grant option 时才能执行 `GRANT`,报错是 `grant option required: <权限> ON <表>`;`REVOKE` 仍然只允许管理员,收回授权会连它的 grant option 一起收掉,且不会级联收回别人转授出去的授权。转授权随角色继承一起传递。
系统目录访问还受目录管理属性控制,具体能力见 [用户与角色](users-and-roles.md)。
目录管理属性示例(仅 SUPERUSER 可设置):
```sql
ALTER USER alice SYSTEM_CATALOG_MANAGER;
ALTER USER alice NOSYSTEM_CATALOG_MANAGER;
```
+48
View File
@@ -0,0 +1,48 @@
# 自定义类型与预定义类型
本文描述 ChuSQL 当前实现。预定义类型是引擎已经支持的基础类型;自定义类型使用 `DOMAIN` 给基础类型命名,便于多个表列复用同一类型定义。
## 自定义类型 DOMAIN
DOMAIN 按数据库保存,保留名称并复用基础类型的校验与运算语义。可引用内置类型或当前库已有 DOMAIN;被列或其他 DOMAIN 引用时不能删除。
创建、使用、查询和删除的语法及限制见 [SQL 语法](syntax.md#自定义类型-domain)。
## 预定义类型的含义
安装期间,引导程序将下列 18 个名称及别名登记到系统类型目录。大小写不影响识别。
| SQL 名称 | 含义与默认行为 | 目录中的默认 `base_type` | 接受参数 |
| --- | --- | --- | --- |
| INT、INTEGER | 整数,两种名称等价 | `int` | 否 |
| BIGINT | 大整数类型名称 | `bigint` | 否 |
| SMALLINT | 小整数类型名称 | `smallint` | 否 |
| VARCHAR | 可变长文本;省略长度时不设声明长度上限 | `str` | 是:`VARCHAR(n)` |
| CHAR | 有长度限制的文本,默认长度 1 | `char(1)` | 是:`CHAR(n)` |
| TEXT、STR | 文本,两种名称等价 | `str` | 否 |
| BOOLEAN、BOOL | 布尔值 `TRUE` / `FALSE` | `bool` | 否 |
| FLOAT、REAL | 浮点数,两种名称等价 | `float` | 否 |
| DOUBLE | 双精度类型名称 | `double` | 否 |
| DECIMAL、NUMERIC | 指定精度和小数位的数值,默认 `(10, 0)` | `decimal(10,0)` | 是:`(p)` 或 `(p,s)` |
| DATE | 日期,使用 `YYYY-MM-DD` 格式 | `date` | 否 |
| TIMESTAMP | 日期与时间 | `timestamp` | 否 |
| BLOB | 二进制数据类型名称 | `blob` | 否 |
上述名称表达类型分类,不表示已经完整复刻 PostgreSQL 的存储和数值行为。当前整数类型共用整数值表示;浮点类型共用浮点值表示;`CHAR(n)` 检查字符长度,不自动补空格。`DECIMAL(p,s)` 要求 `1 <= p <= 308`、`0 <= s <= p`,使用有限浮点表示并按小数位舍入,恰好半值时取偶数;舍入后的绝对值达到 `10^(p-s)` 即报精度溢出。它不能视为精确十进制存储。
## 系统类型目录字段
全局预定义类型目录为 `system.__system_types`,每个名称或别名单独占一条记录。
| 字段 | 含义 |
| --- | --- |
| `id` | 类型记录的持久化编号 |
| `name` | 小写类型名称,例如 `integer` 或 `varchar` |
| `base_type` | 不带显式参数时的规范基础表示;例如 `integer` 对应 `int`,`varchar` 对应 `str` |
| `parameterized` | 该名称是否接受长度、精度等类型参数;不表示具体列已经指定了参数 |
例如,目录中的 `varchar` 记录表示支持 `VARCHAR(n)`;实际列声明为 `VARCHAR(32)` 时,长度 32 保存在列结构中,而不是修改全局目录记录。
该目录是受保护的系统元数据,普通 SQL 不直接展示或修改它。类型执行语义由引擎实现,不能通过手工增加目录行获得新的基础类型。安装登记不会创建任何预定义 DOMAIN;DOMAIN 定义另存于各工作库的 catalog。
首次安装完成管理员初始化后登记类型。重复引导保留一致的记录并补齐缺失项;同名定义冲突会报错,停止安装启动流程,不覆盖现有定义。
+68
View File
@@ -0,0 +1,68 @@
# 用户、角色与 root 属性
ChuSQL 的用户和角色共用一套身份目录及名称空间。用户是允许登录的角色;`CREATE USER` 与 `CREATE ROLE` 的主要区别是默认登录能力。同名用户和角色不能同时存在。
## root 的含义
安装阶段默认创建名为 `root` 的管理员身份,并赋予以下属性:
| 属性 | 初始值 | 含义 |
| --- | --- | --- |
| `can_login` | `true` | 可以使用账号及口令登录 |
| `is_superuser` | `true` | 具有系统管理及数据访问的最高权限 |
| `system_catalog_manager` | `false` | 独立目录管理属性;SUPERUSER 本身已具有有效目录管理能力 |
| `enabled` | `true` | 身份处于启用状态 |
管理员名称可通过安装选项修改。`root` 不是凭名字获得特殊权限:授权判断依据身份属性,其他身份也可被授予 `SUPERUSER`。修改启动参数中的管理员名称不会提升已有普通身份。
口令由安装脚本通过标准输入交给引导程序,经哈希后保存,不写入配置文件。重复引导保留已有口令和属性;已有引导身份被降权或禁用时明确拒绝继续,不自动恢复最高权限。
系统保护最后一个同时满足 `enabled=true`、`can_login=true`、`is_superuser=true` 的身份:不能删除、禁用、取消登录能力或取消最高权限。若要替换 root,应先创建另一个可登录且启用的管理员。
## 默认属性与修改方法
| 创建方式 | 登录能力 | 最高权限 | 启用状态 |
| --- | --- | --- | --- |
| 安装引导管理员(默认 root) | LOGIN | SUPERUSER | ENABLED |
| `CREATE USER` | LOGIN | NOSUPERUSER | ENABLED |
| `CREATE ROLE` | NOLOGIN | NOSUPERUSER | ENABLED |
身份创建与修改语法见 [SQL 语法](syntax.md#账号与权限)。
`ALTER USER` 与 `ALTER ROLE` 的属性、口令修改语法通用。可使用 `LOGIN / NOLOGIN`、`SUPERUSER / NOSUPERUSER`、`ENABLED / DISABLED`、`SYSTEM_CATALOG_MANAGER / NOSYSTEM_CATALOG_MANAGER`;同一条语句不能重复指定同一属性。
SUPERUSER 可设置目录管理属性。该属性默认为 false,不经成员关系继承;只有自身启用且允许登录的身份能使用它。目录管理者可以进入 system 库、查看身份目录、创建普通身份、修改普通身份属性与口令及删除普通用户身份。它不能管理 SUPERUSER 或其他目录管理者,不能授予这两个特权属性,不能执行 `DROP ROLE`,也不会因此获得创建业务数据库、直接修改系统表或读取业务数据的权限。受限身份变更在存储写锁内再次检查目标权限。
## 全部身份及凭据字段
身份元数据保存于隐藏的 `system.__system_identities`;凭据单独保存于 `system.__system_users`。以下字段解释实际持久化内容,不表示都能通过 SQL 修改或查询。
| 身份字段 | 含义 | 管理方式 |
| --- | --- | --- |
| `id` | 身份的稳定编号;删除后不复用,重建同名身份获得新编号 | 系统分配 |
| `user` | 用户或角色名称,共用名称空间,不区分大小写 | 创建时指定;当前不支持改名 |
| `can_login` | 是否允许直接登录;仅允许登录仍不足以保证登录成功,还需启用及正确口令 | `LOGIN / NOLOGIN` |
| `is_superuser` | 是否具有最高权限;不通过角色成员关系继承 | `SUPERUSER / NOSUPERUSER` |
| `system_catalog_manager` | 是否具有受限的系统目录管理能力;不继承、不替代数据访问授权 | `SYSTEM_CATALOG_MANAGER / NOSYSTEM_CATALOG_MANAGER`,仅 SUPERUSER 可设置 |
| `enabled` | 是否启用身份;禁用后不能登录,也不再贡献角色继承权限 | `ENABLED / DISABLED` |
| `identity_version` | 身份目录格式与旧数据迁移标记;旧记录可为 0,当前统一身份格式为 1 | 系统维护;不是会话版本 |
| `registered_at` | 身份创建时间;旧记录缺失时由迁移补齐 | 系统记录 |
| `last_login_at` | 最近一次成功登录时间;尚未登录时为空 | 成功登录时更新 |
| `revision` | 会话校验版本;修改口令或身份属性后递增,使旧登录凭据失效 | 系统维护;不是登录次数 |
| 凭据字段 | 含义 |
| --- | --- |
| `id` | 对应身份目录中的编号 |
| `password_hash` | 口令哈希,不是明文口令;通过口令修改命令更新 |
内部账号协议可将身份字段和 `password_hash` 合并为账号对象,但哈希不属于公开的角色展示内容。`SHOW ROLES` 展示全部身份的编号、名称、登录能力、最高权限、启用状态、目录管理属性和创建时间,不返回口令哈希,也不展示全部内部字段。系统表由身份管理接口维护,不应直接修改。
## 属性、授权与会话的关系
普通用户需要直接授权或通过角色继承获得数据权限。示例见 [SQL 语法](syntax.md#账号与权限)。
`NOLOGIN` 只禁止该身份直接登录,角色仍可贡献继承权限;`DISABLED` 会使该角色停止贡献继承权限。`SUPERUSER` 始终由登录身份自身的属性决定,加入一个最高权限角色不会继承最高权限。
修改身份属性、口令或相关权限会使受影响的既有会话失效;TCP 连接在下一次请求时收到 `unauthorized` 并关闭。删除身份会清理它的授权及双向成员关系;重新创建同名身份不会恢复原有授权。
当前尚未提供 PostgreSQL 的全部角色选项,例如独立的 `CREATEDB`、`CREATEROLE`、连接数限制或口令到期时间。应以本文列出的属性及当前支持的授权命令为准。
+8 -6
View File
@@ -1,6 +1,6 @@
# gate-check — 项目门禁
独立门禁目录,不再放在 `localdata/`(那里只留历史日志)。入口是 `run-gate.ps1`,三条车道并行。
独立门禁目录:脚本与说明都在这一个目录,入口是 `run-gate.ps1`,三条车道并行;车道日志与临时目录写到仓库根的 `logs/`。
## 快速开始
@@ -18,27 +18,29 @@ pwsh -NoProfile -File gate-check/run-gate.ps1 -Only server # 只跑一条车道
| --- | --- | --- |
| build | `cargo build --release` | 先单独跑:Haskell 测试要从 `chusql-core/storage/target/release` 加载动态库 |
| cargo | `cargo test --release`、`cargo clippy --all-targets -- -D warnings` | `-NoLint` 跳过 clippy |
| stack | engine → server → web → cli 的 `stack test <pkg> --fast` | 串行:stack 与快照目录不能并发写 |
| stack | engine → server → web → cli → bootstrap 的 `stack test <pkg> --fast` | 串行:stack 与快照目录不能并发写 |
| comments | `comment-check.ps1`、`comment-purity.ps1` | 注释长度与注释纯度 |
`build` 车道先跑完,再并行启动其余三条;这样动态库刷新与 Haskell 测试不会互相抢文件。
Web 步骤会先显式刷新该工程里的 model、engine、interface 与 server 本地依赖,再运行 Web 测试,避免构建工具依赖复用旧的注册库。刷新失败会使 Web 步骤失败,不继续测试。
## 退出码与日志
- 每步打印一行 `STEP_EXIT <步骤> <码>`;任一非零则 `GATE_EXIT=1`,否则 `GATE_EXIT=0`。
- 末尾打印 `STEPS=...`(各步骤退出码)与总用时。
- 日志在 `gate-check/logs/<车道>.log`(stderr 在 `.err.log`),终端实时转发同一份内容;文件不进版本库。
- 日志在仓库根的 `logs/<车道>.log`(stderr 在 `.err.log`),终端实时转发同一份内容;文件不进版本库。
- `comment-check.ps1` 有问题数时退出 1;`comment-purity.ps1` 只报告差异,恒退出 0。
## 相对旧脚本的提速点
1. 默认不再 `stack clean --full`:改为零清理;`-Clean` 只清各项目自己的工件,`-Full` 才回到旧行为。
2. 车道并行:Rust 测试 + clippy、四个 Haskell 套件、注释检查同时跑。
2. 车道并行:Rust 测试 + clippy、五个 Haskell 套件、注释检查同时跑。
3. 输出流式:旧脚本 `| Out-String` 要等整步跑完才吐字,这里逐行实时转发。
4. 单车道开关 `-Only`,改一个模块时不用全量。
## 约定
- 门禁由项目所有者手动运行;agent 只跑模块级测试,不跑全量门禁。
- 期望基线(随里程碑更新):Engine 357 / Server 88 / Web 56 / CLI 10 / Rust 140。
- 计数与主线进度以 `agent_credientials/project-plan.txt` 为准。
- 期望基线(随里程碑更新):Engine 373 / Server 102 / Web 56 / CLI 13 / Bootstrap 9 / Rust 154。
- 计数与主线进度以 `agent-credientials/project-plan.txt` 为准。
+1 -2
View File
@@ -2,7 +2,7 @@
# 有问题的文件不静默:问题数大于 0 时退出码为 1
param([string]$Root = (Split-Path -Parent $PSScriptRoot), [string[]]$Only)
$codeDirs = @('chusql-core','chusql-server','chusql-web','chusql-cli','chusql-bootstrap','chusql-interface','chusql-benchmark','scripts','gate-check')
$codeDirs = @('chusql-core','chusql-server','chusql-web','chusql-cli','chusql-bootstrap','chusql-interface','benchmark','scripts','gate-check')
$files = @()
foreach ($d in $codeDirs) {
$p = Join-Path $Root $d
@@ -13,7 +13,6 @@ foreach ($d in $codeDirs) {
}
if ($Only) { $files = $files | Where-Object { $n = $_.Name; ($Only | Where-Object { $n -like $_ }).Count -gt 0 } }
$files = $files | Sort-Object FullName
$files = $files | Where-Object { $_.FullName -notmatch '\\localdata\\' }
$problems = @()
$scanned = 0
+2 -2
View File
@@ -3,13 +3,13 @@
param([string]$Root = (Split-Path -Parent $PSScriptRoot))
Set-Location $Root
$dirs = @('chusql-core','chusql-server','chusql-web','chusql-cli','chusql-bootstrap','chusql-interface','chusql-benchmark','scripts','gate-check')
$dirs = @('chusql-core','chusql-server','chusql-web','chusql-cli','chusql-bootstrap','chusql-interface','benchmark','scripts','gate-check')
$files = @()
foreach ($d in $dirs) {
$p = Join-Path $Root $d
if (Test-Path -LiteralPath $p) {
$files += Get-ChildItem -LiteralPath $p -Recurse -File -Include *.hs,*.rs,*.js,*.css,*.html,*.ps1,*.sh |
Where-Object { $_.FullName -notmatch '\\\.stack-work\\|\\dist-newstyle\\|\\target\\|\\node_modules\\|\\localdata\\|\\logs\\|\\tmp\\' }
Where-Object { $_.FullName -notmatch '\\\.stack-work\\|\\dist-newstyle\\|\\target\\|\\node_modules\\|\\logs\\|\\tmp\\' }
}
}
$files = $files | Sort-Object FullName
+3 -3
View File
@@ -13,8 +13,8 @@ $env:GHC_CHARENC = 'UTF-8'
try { [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 } catch { }
$OutputEncoding = [System.Text.Encoding]::UTF8
$LogDir = Join-Path $PSScriptRoot 'logs'
$TmpDir = Join-Path $PSScriptRoot 'tmp'
$LogDir = Join-Path $Root 'logs'
$TmpDir = Join-Path $LogDir 'tmp'
New-Item -ItemType Directory -Force -Path $LogDir, $TmpDir | Out-Null
$env:PATH = (Join-Path $Root 'chusql-core\storage\target\release') + ';' + $env:PATH
$env:TEMP = $TmpDir
@@ -77,7 +77,7 @@ function Wait-Lanes($Lanes) {
# 清理本地依赖工件:-Clean 只清各项目自己的,-Full 连依赖副本一起清
function Invoke-Clean {
foreach ($dir in @('chusql-core\engine', 'chusql-server', 'chusql-web', 'chusql-cli')) {
foreach ($dir in @('chusql-core\engine', 'chusql-server', 'chusql-web', 'chusql-cli', 'chusql-bootstrap')) {
$argv = @('clean')
if ($Full) { $argv += '--full' }
Write-Host "== stack $($argv -join ' ') in $dir" -ForegroundColor DarkGray
+11 -2
View File
@@ -12,16 +12,24 @@ $OutputEncoding = [System.Text.Encoding]::UTF8
$codes = @()
# 跑一个步骤:切目录、输出原样透传、记录退出码
function Invoke-Step([string]$Label, [string]$Dir, [string[]]$Argv) {
function Invoke-Step([string]$Label, [string]$Dir, [string[]]$Argv, [string[]]$RefreshPackages = @()) {
Write-Host "=== $Label ==="
$code = 1
Push-Location -LiteralPath $Dir
try {
$exe = $Argv[0]
$rest = @($Argv | Select-Object -Skip 1)
$ready = $true
if ($RefreshPackages.Count -gt 0) {
& stack build @RefreshPackages --fast --force-dirty
$code = [int]$global:LASTEXITCODE
$ready = $code -eq 0
}
if ($ready) {
& $exe @rest
if ($null -ne $global:LASTEXITCODE) { $code = [int]$global:LASTEXITCODE }
}
}
catch {
Write-Host "--- $Label raised: $_"
$code = 1
@@ -47,8 +55,9 @@ switch ($Kind) {
'stack' {
Invoke-Step 'engine' $engine @('stack', 'test', 'chusql-core-engine', '--fast')
Invoke-Step 'server' (Join-Path $Root 'chusql-server') @('stack', 'test', 'chusql-server', '--fast')
Invoke-Step 'web' (Join-Path $Root 'chusql-web') @('stack', 'test', 'chusql-web', '--fast')
Invoke-Step 'web' (Join-Path $Root 'chusql-web') @('stack', 'test', 'chusql-web', '--fast') @('chusql-core-model', 'chusql-core-engine', 'chusql-interface', 'chusql-server')
Invoke-Step 'cli' (Join-Path $Root 'chusql-cli') @('stack', 'test', 'chusql-cli', '--fast')
Invoke-Step 'bootstrap' (Join-Path $Root 'chusql-bootstrap') @('stack', 'test', 'chusql-bootstrap', '--fast')
}
'comments' {
$check = Join-Path $PSScriptRoot 'comment-check.ps1'
+73
View File
@@ -0,0 +1,73 @@
# ChuSQL configuration for Linux (also used by the Unix installer).
# Installation resolves @DATA_DIR@ and @LOG_DIR@ to absolute paths.
# Omitted options use built-in defaults; command-line options take precedence.
[page]
# Data page size in bytes: a power of two from 512 to 65536.
# Keep this value unchanged when opening existing database files.
size = 4096
[btree]
# Maximum children per B+ tree node (at least 3); the node must fit in one page.
# Keep this value unchanged when opening existing index files.
order = 4
[buffer]
# Maximum number of cached pages (at least 1); memory grows with page size.
pool_size = 1024
[storage]
# Database root: system catalog, business databases, indexes, WAL and checkpoints.
# Default: ${XDG_DATA_HOME:-$HOME/.local/share}/chusql/data.
# --data-dir overrides this location during installation; changing it moves no data.
data_dir = "@DATA_DIR@"
# Diagnostic logs, separate from WAL stored under data_dir.
# Installed default: ${XDG_STATE_HOME:-$HOME/.local/state}/chusql/logs.
# Use absolute paths; relative paths are resolved from the process working directory.
log_files = "@LOG_DIR@"
[server]
# TCP address to bind; 127.0.0.1 accepts local connections only.
listen_host = "127.0.0.1"
# TCP database port; choose an unused port from 1 to 65535.
port = 7777
# Maximum size of a single protocol message in bytes.
max_message = 1048576
# Maximum number of rows returned by a query through the database server.
max_rows = 1000
[log]
# Diagnostic verbosity: off, error, warn, info or debug.
level = "info"
[web]
# HTTP address to bind; use an external address to expose the web interface.
listen_host = "127.0.0.1"
# HTTP port; choose an unused port from 1 to 65535, different from the TCP port.
port = 7778
# Send session cookies over HTTPS only; enable behind an HTTPS endpoint.
cookie_secure = false
# Session inactivity timeout in seconds.
session_idle = 28800
# Maximum total session lifetime in seconds, regardless of activity.
session_max = 86400
# Maximum failed login attempts allowed within the login window.
login_max_attempts = 5
# Login rate-limit window in seconds.
login_window = 300
# Maximum HTTP request body size in bytes.
body_limit = 65536
# Default number of rows displayed per results page.
rows_per_page = 25
# Maximum page size accepted by the web interface.
max_page_size = 500
# Maximum number of query result rows retained by the web interface.
max_rows = 1000
# Maximum SQL input length in characters accepted by the web interface.
max_sql_length = 20000
# Create demonstration data on startup; keep disabled for normal installations.
seed = false
# Minimum password length for ordinary accounts, in characters.
password_min_length = 12
# Minimum character classes required: lowercase, uppercase, digits and symbols.
password_classes = 2
+73
View File
@@ -0,0 +1,73 @@
# ChuSQL configuration for Windows.
# Installation resolves @DATA_DIR@ and @LOG_DIR@ to absolute paths.
# Omitted options use built-in defaults; command-line options take precedence.
[page]
# Data page size in bytes: a power of two from 512 to 65536.
# Keep this value unchanged when opening existing database files.
size = 4096
[btree]
# Maximum children per B+ tree node (at least 3); the node must fit in one page.
# Keep this value unchanged when opening existing index files.
order = 4
[buffer]
# Maximum number of cached pages (at least 1); memory grows with page size.
pool_size = 1024
[storage]
# Database root: system catalog, business databases, indexes, WAL and checkpoints.
# Default: %LOCALAPPDATA%/ChuSQL/data.
# -DataDir overrides this location during installation; changing it moves no data.
data_dir = "@DATA_DIR@"
# Diagnostic logs, separate from WAL stored under data_dir.
# Installed default: %LOCALAPPDATA%/ChuSQL/logs.
# Use absolute paths; relative paths are resolved from the process working directory.
log_files = "@LOG_DIR@"
[server]
# TCP address to bind; 127.0.0.1 accepts local connections only.
listen_host = "127.0.0.1"
# TCP database port; choose an unused port from 1 to 65535.
port = 7777
# Maximum size of a single protocol message in bytes.
max_message = 1048576
# Maximum number of rows returned by a query through the database server.
max_rows = 1000
[log]
# Diagnostic verbosity: off, error, warn, info or debug.
level = "info"
[web]
# HTTP address to bind; use an external address to expose the web interface.
listen_host = "127.0.0.1"
# HTTP port; choose an unused port from 1 to 65535, different from the TCP port.
port = 7778
# Send session cookies over HTTPS only; enable behind an HTTPS endpoint.
cookie_secure = false
# Session inactivity timeout in seconds.
session_idle = 28800
# Maximum total session lifetime in seconds, regardless of activity.
session_max = 86400
# Maximum failed login attempts allowed within the login window.
login_max_attempts = 5
# Login rate-limit window in seconds.
login_window = 300
# Maximum HTTP request body size in bytes.
body_limit = 65536
# Default number of rows displayed per results page.
rows_per_page = 25
# Maximum page size accepted by the web interface.
max_page_size = 500
# Maximum number of query result rows retained by the web interface.
max_rows = 1000
# Maximum SQL input length in characters accepted by the web interface.
max_sql_length = 20000
# Create demonstration data on startup; keep disabled for normal installations.
seed = false
# Minimum password length for ordinary accounts, in characters.
password_min_length = 12
# Minimum character classes required: lowercase, uppercase, digits and symbols.
password_classes = 2
-57
View File
@@ -1,57 +0,0 @@
# ChuSQL 全局配置。
#
# 整条链路共用这一份文件,按分区各管各的,互不影响:
# 数据库服务:[server](TCP 监听地址、端口、单行上限、单次结果行数上限)
# —— 只有它装入 chusql_core_storage 动态库并独占数据目录
# Web / 命令行:[web](账号名、会话、分页上限、静态目录),都经 [server] 走
# 数据目录在 [storage] data_dir;这一行是安装时会替换的占位:
# 不写就按平台惯例(Windows %LOCALAPPDATA%\ChuSQL\data,
# Unix ${XDG_DATA_HOME:-$HOME/.local/share}/chusql/data)。
# 每一层只读自己那几段,读完就关,不长期占着文件句柄,别的层照样能读。
# 只有这一份文件说了算:没有任何环境变量覆盖。
# 位置是固定的:Windows 在 %APPDATA%\ChuSQL\chusql.toml,其它平台在
# $XDG_CONFIG_HOME/ChuSQL/chusql.toml(没设就是 ~/.config/ChuSQL/chusql.toml)。
# 想临时读别处,三个可执行文件都认命令行 --config <path>。
[page]
size = 4096
[btree]
order = 4
[buffer]
pool_size = 1024
[storage]
# 安装时被替换成实际数据目录(--data-dir / -DataDir);不写就按平台惯例:
# Windows %LOCALAPPDATA%\ChuSQL\data,Unix ${XDG_DATA_HOME:-$HOME/.local/share}/chusql/data
data_dir = "../localdata"
# TCP 数据库服务(chusql-server)。单独一段,别层的进程不认识它、也不需要认识。
[server]
host = "127.0.0.1"
port = 7777
max_message = 1048576
max_rows = 1000
[log]
level = "info"
[web]
host = "127.0.0.1"
port = 7778
static_dir = "static"
# 管理员账号名。口令存在系统表 __system_users 里,不写进这份文件;
# 首启为空口令,登录后用 ALTER USER 或在 Web 账号页设置。
user = "root"
# 会话与登录限流
session_idle = 28800
session_max = 86400
login_max_attempts = 5
login_window = 300
# 请求与分页上限
body_limit = 65536
rows_per_page = 25
max_page_size = 500
max_rows = 1000
max_sql_length = 20000
+10 -8
View File
@@ -1,6 +1,6 @@
$ErrorActionPreference = 'Stop'
# csql-web:先起独占数据目录的 server,再起 Web 服务,配置读 chusql.toml。
# csql-web:先起独占数据目录的 server,再起 Web 服务,配置读 settings.toml。
# 打印错误并退出 1。
function Fail([string]$msg) { Write-Host "!! $msg" -ForegroundColor Red; exit 1 }
@@ -11,16 +11,16 @@ $home_dir = Split-Path -Parent $MyInvocation.MyCommand.Path
$bin = Join-Path $home_dir 'bin'
$config_dir = if ($env:APPDATA) { Join-Path $env:APPDATA 'ChuSQL' } else { Join-Path $home_dir 'config' }
$config = Join-Path $config_dir 'chusql.toml'
$config = Join-Path $config_dir 'settings.toml'
if (-not (Test-Path $config)) { Fail "config not found: $config" }
# 从配置里取一个 [web] 段的值。
function Get-WebSetting([string]$Key, [string]$Default) {
# 从配置里取某个分区的一个值。
function Get-SectionSetting([string]$Section, [string]$Key, [string]$Default) {
$section = ''
$pattern = '^\s*' + [regex]::Escape($Key) + '\s*='
foreach ($raw in (Get-Content -Path $config -Encoding UTF8)) {
if ($raw -match '^\s*\[') { $section = ($raw -replace '[\[\]\s]', ''); continue }
if ($section -ne 'web') { continue }
if ($section -ne $Section) { continue }
$line = $raw -replace '#.*$', ''
if ($line -match $pattern) {
return (($line -replace '^[^=]*=\s*', '').Trim().Trim('"'))
@@ -34,9 +34,11 @@ $web_exe = Join-Path $bin 'chusql-web.exe'
if (-not (Test-Path $server_exe)) { Fail "server binary not found in $bin" }
if (-not (Test-Path $web_exe)) { Fail "web binary not found in $bin" }
$host_addr = Get-WebSetting 'host' '127.0.0.1'
$port = Get-WebSetting 'port' '7778'
$log_dir = Join-Path $home_dir 'logs'
# 监听地址与端口取 [web] 段,日志目录取 [storage] log_files(相对路径以安装目录为基准)。
$host_addr = Get-SectionSetting 'web' 'listen_host' '127.0.0.1'
$port = Get-SectionSetting 'web' 'port' '7778'
$log_setting = Get-SectionSetting 'storage' 'log_files' './logs'
$log_dir = if ([System.IO.Path]::IsPathRooted($log_setting)) { $log_setting } else { Join-Path $home_dir $log_setting }
New-Item -ItemType Directory -Force -Path $log_dir | Out-Null
$server = $null
+15 -10
View File
@@ -1,24 +1,24 @@
#!/bin/sh
set -eu
# csql-web:先起独占数据目录的 server,再起 Web 服务,配置读 chusql.toml。
# csql-web:先起独占数据目录的 server,再起 Web 服务,配置读 settings.toml。
home_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
bin="$home_dir/bin"
# 和在 Windows 的 csql-web.ps1(Start-Process -WorkingDirectory)保持一致:
# 先切到安装目录,配置里写相对的 static_dir / data_dir 才有确定的解析基准。
# 先切到安装目录,配置里写相对的 log_files 才有确定的解析基准。
cd "$home_dir"
config_dir="${XDG_CONFIG_HOME:-$HOME/.config}/ChuSQL"
config="$config_dir/chusql.toml"
config="$config_dir/settings.toml"
[ -f "$config" ] || { echo "!! config not found: $config" >&2; exit 1; }
# 从配置里取一个 [web] 段的值。
web_setting() {
awk -v key="$1" -v def="$2" '
# 从配置里取某分区的一个值。
section_setting() {
awk -v section_key="$1" -v key="$2" -v def="$3" '
/^[[:space:]]*\[/ { section = $0; gsub(/[][[:space:]]/, "", section); next }
section != "web" { next }
section != section_key { next }
{
line = $0
sub(/#.*/, "", line)
@@ -40,9 +40,14 @@ web_exe="$bin/chusql-web"
[ -x "$server_exe" ] || { echo "!! server binary not found in $bin" >&2; exit 1; }
[ -x "$web_exe" ] || { echo "!! web binary not found in $bin" >&2; exit 1; }
host=$(web_setting host 127.0.0.1)
port=$(web_setting port 7778)
log_dir="$home_dir/logs"
# 监听地址与端口取 [web] 段,日志目录取 [storage] log_files(相对路径以安装目录为基准)。
host=$(section_setting web listen_host 127.0.0.1)
port=$(section_setting web port 7778)
log_setting=$(section_setting storage log_files ./logs)
case "$log_setting" in
/*) log_dir="$log_setting" ;;
*) log_dir="$home_dir/$log_setting" ;;
esac
mkdir -p "$log_dir"
# server 先起来:它进程内装入存储库、独占数据目录,Web 的存储请求都经它转发
+18 -18
View File
@@ -166,7 +166,7 @@ if (-not $Component) {
}
# 管理员口令是装机必答项:交互时当场设,非交互时给 -Password 或
# CHUSQL_ADMIN_PASSWORD。口令只经 stdin 传给 csql-bootstrap,不落 chusql.toml。
# CHUSQL_ADMIN_PASSWORD。口令只经 stdin 传给 csql-bootstrap,不落 settings.toml。
$adminPassword = "$env:CHUSQL_ADMIN_PASSWORD"
if ($Password) { $adminPassword = $Password }
if ($canPrompt) {
@@ -202,7 +202,8 @@ $downloaded = $false
$tempDir = ''
if (-not $InstallDir) { $InstallDir = Join-Path $env:LOCALAPPDATA 'ChuSQL' }
if (-not $DataDir) { $DataDir = Join-Path $InstallDir 'data' }
if (-not $DataDir) { $DataDir = Join-Path (Join-Path $env:LOCALAPPDATA 'ChuSQL') 'data' }
$LogDir = Join-Path (Join-Path $env:LOCALAPPDATA 'ChuSQL') 'logs'
$InstallDir = [System.IO.Path]::GetFullPath($InstallDir)
$DataDir = [System.IO.Path]::GetFullPath($DataDir)
@@ -289,8 +290,8 @@ if ($wantWeb) {
if ($wantCli) {
if (-not (Test-Path (Join-Path $pack 'bin\csql.exe'))) { Fail 'package is incomplete: bin\csql.exe not found' }
}
$template = Join-Path $pack 'scripts\chusql.toml'
if (-not (Test-Path $template)) { Fail 'package is incomplete: scripts\chusql.toml not found' }
$template = Join-Path $pack 'resources\settings.toml.windows'
if (-not (Test-Path $template)) { Fail 'package is incomplete: resources\settings.toml.windows not found' }
Say "ChuSQL installer"
Say (" version {0}" -f $Version)
@@ -298,12 +299,12 @@ Say (" components {0}" -f $componentLabel)
if ($downloaded) { Say (" release {0}" -f $Version) }
Say (" install to {0}" -f $InstallDir)
Say (" data dir {0}" -f $DataDir)
Say (" root user {0} (password asked above, kept out of chusql.toml)" -f $RootUser)
Say (" root user {0} (password asked above, kept out of settings.toml)" -f $RootUser)
# ---- 释放文件 ----
# 包是合在一起的(web 和 cli 都在),这里按这次的选择逐个释放:没选的组件不落地
Step 'Installing files'
New-Item -ItemType Directory -Force -Path (Join-Path $InstallDir 'bin'), (Join-Path $InstallDir 'logs'), $DataDir | Out-Null
New-Item -ItemType Directory -Force -Path (Join-Path $InstallDir 'bin'), $LogDir, $DataDir | Out-Null
Copy-Item $storageLib (Join-Path $InstallDir 'bin') -Force
Copy-Item (Join-Path $pack 'bin\chusql-server.exe') (Join-Path $InstallDir 'bin') -Force
Copy-Item (Join-Path $pack 'bin\csql-bootstrap.exe') (Join-Path $InstallDir 'bin') -Force
@@ -325,26 +326,25 @@ if ($wantWeb) {
}
# ---- 写全局配置 ----
Step 'Writing chusql.toml'
Step 'Writing settings.toml'
$configDir = if ($env:APPDATA) { Join-Path $env:APPDATA 'ChuSQL' } else { Join-Path $InstallDir 'config' }
$configFile = Join-Path $configDir 'chusql.toml'
$configFile = Join-Path $configDir 'settings.toml'
$toml = Get-Content -Path $template -Raw -Encoding UTF8
$toml = $toml -replace '(?m)^(\s*user\s*=\s*).*$', "`$1`"$RootUser`""
$toml = $toml -replace '(?m)^(\s*data_dir\s*=\s*).*$', "`$1`"$($DataDir -replace '\\', '/')`""
$toml = $toml.Replace('@DATA_DIR@', ($DataDir -replace '\\', '/')).Replace('@LOG_DIR@', ($LogDir -replace '\\', '/'))
New-Item -ItemType Directory -Force -Path $configDir | Out-Null
Set-Content -Path $configFile -Value $toml -Encoding UTF8
Say (" config file {0}" -f $configFile)
# ---- 引导系统目录 ----
# csql-bootstrap 建 system 数据库和 __system_users 表,口令只走 stdin,不进 chusql.toml
Step 'Creating the system catalog'
# 引导身份目录、root 与预装类型,口令通过标准输入传递
Step 'Initializing the administrator identity and preinstalled types'
$bootstrap = Join-Path $InstallDir 'bin\csql-bootstrap.exe'
$bootstrapArgs = @('--config', $configFile, '--user', $RootUser)
$bootstrapArgs += '--password-stdin'
$bootstrapOutput = $adminPassword | & $bootstrap @bootstrapArgs 2>&1
$bootstrapOutput | ForEach-Object { Say (" {0}" -f $_) }
if ($LASTEXITCODE -ne 0) {
Fail ("the bootstrap program failed (exit {0}); the system catalog is not ready" -f $LASTEXITCODE)
Fail ("the bootstrap program failed (exit {0}); identities or preinstalled types are not ready" -f $LASTEXITCODE)
}
# ---- 命令入口 ----
@@ -401,11 +401,11 @@ $serverPid = 0
if ($NoStart) {
Say ' -NoStart: the server was not started'
} else {
$serverLog = Join-Path $InstallDir 'logs\server.err.log'
$serverLog = Join-Path $LogDir 'server.err.log'
$serverProc = Start-Process -FilePath $serverPath `
-ArgumentList @('--config', $configFile) `
-ArgumentList @('--config', $configFile, '--user', $RootUser) `
-WorkingDirectory $InstallDir -WindowStyle Hidden -PassThru `
-RedirectStandardOutput (Join-Path $InstallDir 'logs\server.log') `
-RedirectStandardOutput (Join-Path $LogDir 'server.log') `
-RedirectStandardError $serverLog
Start-Sleep -Milliseconds 1200
if ($serverProc.HasExited) {
@@ -417,13 +417,13 @@ if ($NoStart) {
}
Step 'Done'
Say (" chusql.toml {0}" -f $configFile)
Say (" settings.toml {0}" -f $configFile)
Say (" data {0}" -f $DataDir)
$startWith = @()
if ($wantWeb) { $startWith += 'csql-web' }
if ($wantCli) { $startWith += 'csql' }
Say (" start with {0}" -f ($startWith -join ', '))
Say (" tcp server {0} (listens on [server] host/port, defaults 127.0.0.1:7777)" -f $serverPath)
Say (" tcp server {0} (listens on [server] listen_host/port, defaults 127.0.0.1:7777)" -f $serverPath)
if ($serverPid -gt 0) {
Say (" running pid {0} (stop it with: Stop-Process -Id {0})" -f $serverPid)
} else {
+25 -24
View File
@@ -30,7 +30,7 @@ usage: ./install.sh [--component web|cli|both] [options]
web front end (browser UI)? default no [y/N]
it then asks whether to give the administrator account a
password (default no); that password goes straight into the
system catalog and is never written to chusql.toml
system catalog and is never written to settings.toml
--interactive ask even when there is no terminal; the answers are
read from stdin (one per line)
@@ -129,7 +129,8 @@ case "$self" in
esac
[ -n "$install_dir" ] || install_dir="$HOME/.local/share/chusql"
[ -n "$data_dir" ] || data_dir="$install_dir/data"
[ -n "$data_dir" ] || data_dir="${XDG_DATA_HOME:-$HOME/.local/share}/chusql/data"
log_dir="${XDG_STATE_HOME:-$HOME/.local/state}/chusql/logs"
# ---- 交互:先确定答案从哪来 ----
# 管道安装(curl ... | sh -s --)时 stdin 是脚本本身,所以优先开 /dev/tty;
@@ -203,7 +204,7 @@ if [ "$component" = '' ] && [ "$list_versions" = 'no' ]; then
fi
# 管理员口令是装机必答项:交互时当场设,非交互时给 --password 或
# CHUSQL_ADMIN_PASSWORD。口令只经 stdin 传给 csql-bootstrap,不落 chusql.toml。
# CHUSQL_ADMIN_PASSWORD。口令只经 stdin 传给 csql-bootstrap,不落 settings.toml。
admin_password="${CHUSQL_ADMIN_PASSWORD:-}"
if [ "$list_versions" = 'no' ]; then
if [ -n "$password_arg" ]; then
@@ -491,7 +492,7 @@ if [ "$from_source" = 'yes' ]; then
note "source $src"
fi
[ -f "$src/chusql-core/storage/Cargo.toml" ] || fail "not a ChuSQL checkout (chusql-core/storage/Cargo.toml missing): $src"
[ -f "$src/scripts/chusql.toml" ] || fail "not a ChuSQL checkout (scripts/chusql.toml missing): $src"
[ -f "$src/resources/settings.toml.linux" ] || fail "not a ChuSQL checkout (resources/settings.toml.linux missing): $src"
if command -v pkg-config >/dev/null 2>&1 && ! pkg-config --exists zlib 2>/dev/null; then
note 'hint the Haskell zlib binding needs the C library: apt-get install zlib1g-dev (or dnf install zlib-devel)'
@@ -504,9 +505,9 @@ if [ "$from_source" = 'yes' ]; then
step 'Staging the package'
pack="$tmp/stage"
mkdir -p "$pack/bin" "$pack/scripts"
mkdir -p "$pack/bin" "$pack/resources"
cp "$storage_lib" "$pack/bin/"
cp "$src/scripts/chusql.toml" "$pack/scripts/"
cp "$src/resources/settings.toml.linux" "$pack/resources/"
cp "$src/scripts/install.sh" "$pack/"
for comp in $wanted; do
if [ "$comp" = 'web' ]; then
@@ -605,19 +606,19 @@ fi
if [ "$want_cli" = 'yes' ]; then
[ -f "$pack/bin/csql" ] || fail 'package is incomplete: bin/csql not found'
fi
[ -f "$pack/scripts/chusql.toml" ] || fail 'package is incomplete: scripts/chusql.toml not found'
[ -f "$pack/resources/settings.toml.linux" ] || fail 'package is incomplete: resources/settings.toml.linux not found'
echo 'ChuSQL installer'
echo " version $version"
echo " components $component_label"
echo " install to $install_dir"
echo " data dir $data_dir"
echo " root user $root_user (password asked above, kept out of chusql.toml)"
echo " root user $root_user (password asked above, kept out of settings.toml)"
# ---- 释放文件 ----
# 包是合在一起的(web 和 cli 都在),这里按这次的选择逐个释放:没选的组件不落地
step 'Installing files'
mkdir -p "$install_dir/bin" "$install_dir/logs" "$data_dir"
mkdir -p "$install_dir/bin" "$log_dir" "$data_dir"
cp "$pack/bin/chusql-server" "$install_dir/bin/"
cp "$pack/bin/csql-bootstrap" "$install_dir/bin/"
for lib in "$pack/bin/"libchusql_core_storage.*; do
@@ -641,22 +642,22 @@ fi
chmod +x "$install_dir/bin/"* 2>/dev/null || true
# ---- 写全局配置 ----
step 'Writing chusql.toml'
step 'Writing settings.toml'
config_dir="${XDG_CONFIG_HOME:-$HOME/.config}/ChuSQL"
config_file="$config_dir/chusql.toml"
config_file="$config_dir/settings.toml"
mkdir -p "$config_dir"
sed -e "s|^\(user[[:space:]]*=[[:space:]]*\).*|\1\"$root_user\"|" \
-e "s|^\(data_dir[[:space:]]*=[[:space:]]*\).*|\1\"$data_dir\"|" \
"$pack/scripts/chusql.toml" > "$config_file"
sed -e "s|@DATA_DIR@|$(printf '%s' "$data_dir" | sed 's/[\&|]/\\&/g')|g" \
-e "s|@LOG_DIR@|$(printf '%s' "$log_dir" | sed 's/[\&|]/\\&/g')|g" \
"$pack/resources/settings.toml.linux" > "$config_file"
echo " config file $config_file"
# ---- 引导系统目录 ----
# csql-bootstrap 建 system 数据库和 __system_users 表;口令只走 stdin,不进 chusql.toml
step 'Creating the system catalog'
# 引导身份目录、root 与预装类型,口令通过标准输入传递
step 'Initializing the administrator identity and preinstalled types'
bootstrap="$install_dir/bin/csql-bootstrap"
[ -n "$admin_password" ] || fail 'the administrator password is required'
printf '%s\n' "$admin_password" | "$bootstrap" --config "$config_file" --user "$root_user" --password-stdin ||
fail 'the bootstrap program failed; the system catalog is not ready'
fail 'the bootstrap program failed; identities or preinstalled types are not ready'
# ---- 命令入口 ----
step 'Creating commands'
@@ -717,19 +718,19 @@ if [ "$no_start" = 'yes' ]; then
note '--no-start: the server was not started'
else
step 'Starting the server'
mkdir -p "$install_dir/logs"
nohup "$server_bin" --config "$config_file" > "$install_dir/logs/server.log" 2> "$install_dir/logs/server.err.log" &
mkdir -p "$log_dir"
nohup "$server_bin" --config "$config_file" --user "$root_user" > "$log_dir/server.log" 2> "$log_dir/server.err.log" &
server_pid=$!
sleep 1
if ! kill -0 "$server_pid" 2>/dev/null; then
fail "the server stopped right away; look at $install_dir/logs/server.err.log"
fail "the server stopped right away; look at $log_dir/server.err.log"
fi
note "server running, pid $server_pid"
note "log $install_dir/logs/server.err.log"
note "log $log_dir/server.err.log"
fi
step 'Done'
echo " chusql.toml $config_file"
echo " settings.toml $config_file"
echo " data $data_dir"
start_with=''
if [ "$want_web" = 'yes' ]; then start_with='csql-web'; fi
@@ -737,9 +738,9 @@ if [ "$want_cli" = 'yes' ]; then
if [ -n "$start_with" ]; then start_with="$start_with, csql"; else start_with='csql'; fi
fi
echo " start with $start_with"
echo " tcp server $server_bin (listens on [server] host/port, defaults 127.0.0.1:7777)"
echo " tcp server $server_bin (listens on [server] listen_host/port, defaults 127.0.0.1:7777)"
if [ -n "$server_pid" ]; then
echo " running pid $server_pid (stop it with: kill $server_pid)"
else
echo " start it $server_bin --config \"$config_file\""
echo " start it $server_bin --config \"$config_file\" --user $root_user"
fi
+12 -5
View File
@@ -57,7 +57,7 @@ function Assert-Stage([string]$stage) {
'bin\csql-bootstrap.exe',
'static\index.html',
'csql-web.ps1',
'scripts\chusql.toml',
'resources\settings.toml.windows',
'install.ps1',
'install.sh',
'uninstall.ps1',
@@ -110,7 +110,10 @@ function Test-Installed([string]$name, [string]$comp) {
(Join-Path $opt 'bin\chusql-server.exe'),
(Join-Path $opt 'bin\csql-bootstrap.exe'),
(Join-Path $data 'system\catalog.json'),
(Join-Path $appdata 'ChuSQL\chusql.toml')
(Join-Path $data 'system\__system_identities.db'),
(Join-Path $data 'system\__system_users.db'),
(Join-Path $data 'system\__system_types.db'),
(Join-Path $appdata 'ChuSQL\settings.toml')
)
$forbidden = @()
if ($comp -eq 'web') {
@@ -130,7 +133,11 @@ function Test-Installed([string]$name, [string]$comp) {
}
foreach ($f in $checks) { if (-not (Test-Path $f)) { $ok = $false; Write-Host " missing: $f" } }
foreach ($f in $forbidden) { if (Test-Path $f) { $ok = $false; Write-Host " should not be there: $f" } }
$config = Join-Path $appdata 'ChuSQL\chusql.toml'
$systemCatalog = Get-Content -LiteralPath (Join-Path $data 'system\catalog.json') -Raw | ConvertFrom-Json
if (-not $systemCatalog.tables.__system_types.system -or $systemCatalog.tables.__system_types.row_count -lt 18) {
Fail "$name`: preinstalled types were not initialized during installation"
}
$config = Join-Path $appdata 'ChuSQL\settings.toml'
$expectDataDir = $data -replace '\\', '/'
if (-not (Test-Path $config) -or -not (Select-String -Path $config -SimpleMatch "data_dir = ""$expectDataDir""" -Quiet)) {
$ok = $false
@@ -190,7 +197,7 @@ New-Item -ItemType Directory -Force -Path $OutDir | Out-Null
try {
Step "Packing $name"
if (Test-Path $stageRoot) { Remove-Item -Recurse -Force $stageRoot }
New-Item -ItemType Directory -Force -Path (Join-Path $stage 'bin'), (Join-Path $stage 'scripts') | Out-Null
New-Item -ItemType Directory -Force -Path (Join-Path $stage 'bin'), (Join-Path $stage 'resources') | Out-Null
$storage = Join-Path $repoRoot 'chusql-core\storage\target\release\chusql_core_storage.dll'
if (-not (Test-Path $storage)) { Fail 'chusql-core\storage\target\release\chusql_core_storage.dll is not built (run without -NoBuild)' }
@@ -227,7 +234,7 @@ try {
Where-Object { $_.Name -notlike 'chusql*' } |
ForEach-Object { Copy-Item $_.FullName (Join-Path $stage 'bin') }
Copy-Item (Join-Path $repoRoot 'scripts\chusql.toml') (Join-Path $stage 'scripts')
Copy-Item (Join-Path $repoRoot 'resources\settings.toml.windows') (Join-Path $stage 'resources')
Copy-Item (Join-Path $repoRoot 'scripts\install.ps1'), (Join-Path $repoRoot 'scripts\install.sh') $stage
Copy-Item (Join-Path $repoRoot 'scripts\uninstall.ps1'), (Join-Path $repoRoot 'scripts\uninstall.sh') $stage
New-Item -ItemType Directory -Force -Path (Join-Path $stage 'static') | Out-Null
Loaded 100 of 104 files, more files were not shown because too many files have changed in this diff. Show more