rust部分推进:进程间通信(Windows平台命名管线)、内存页表、B+树、WAL恢复系统、动态配置
This commit is contained in:
1 parent
91ce26d7f0
commit
e428265357
41 files changed
+4539
-152
No files matched your search
@@ -35,6 +35,9 @@ cabal.project.local~
|
||||
target/
|
||||
**/*.rs.bk
|
||||
|
||||
# 运行时数据(Rust 服务默认数据目录 CHUSQL_DATA_DIR=data/),不入库
|
||||
chusql-storage/data/
|
||||
|
||||
# Cargo.lock 要入库(本项目是要产出可执行程序的 crate,不是纯库)
|
||||
|
||||
# ------------------------------------------------------------
|
||||
|
||||
+38
-11
@@ -1,6 +1,6 @@
|
||||
[
|
||||
{
|
||||
"label": "stack: build",
|
||||
"label": "haskell: stack build",
|
||||
"command": "stack build",
|
||||
"cwd": "$ZED_WORKTREE_ROOT/chusql-engine",
|
||||
"reveal": "always",
|
||||
@@ -8,15 +8,7 @@
|
||||
"save": "all",
|
||||
},
|
||||
{
|
||||
"label": "stack: run",
|
||||
"command": "stack run",
|
||||
"cwd": "$ZED_WORKTREE_ROOT/chusql-engine",
|
||||
"reveal": "always",
|
||||
"hide": "never",
|
||||
"save": "all",
|
||||
},
|
||||
{
|
||||
"label": "stack: test",
|
||||
"label": "haskell: stack test",
|
||||
"command": "stack test",
|
||||
"cwd": "$ZED_WORKTREE_ROOT/chusql-engine",
|
||||
"reveal": "always",
|
||||
@@ -24,11 +16,46 @@
|
||||
"save": "all",
|
||||
},
|
||||
{
|
||||
"label": "stack: repl",
|
||||
"label": "haskell: stack run",
|
||||
"command": "stack run",
|
||||
"cwd": "$ZED_WORKTREE_ROOT/chusql-engine",
|
||||
"reveal": "always",
|
||||
"hide": "never",
|
||||
"save": "all",
|
||||
},
|
||||
{
|
||||
"label": "haskell: stack repl",
|
||||
"command": "stack ghci",
|
||||
"cwd": "$ZED_WORKTREE_ROOT/chusql-engine",
|
||||
"reveal": "always",
|
||||
"hide": "never",
|
||||
"save": "all",
|
||||
},
|
||||
{
|
||||
"label": "rust: cargo check",
|
||||
"command": "cargo",
|
||||
"args": ["check", "--all-targets"],
|
||||
"cwd": "$ZED_WORKTREE_ROOT/chusql-storage",
|
||||
"reveal": "always",
|
||||
"hide": "on_success",
|
||||
"save": "all",
|
||||
},
|
||||
{
|
||||
"label": "rust: cargo test",
|
||||
"command": "cargo",
|
||||
"args": ["test"],
|
||||
"cwd": "$ZED_WORKTREE_ROOT/chusql-storage",
|
||||
"reveal": "always",
|
||||
"hide": "never",
|
||||
"save": "all",
|
||||
},
|
||||
{
|
||||
"label": "rust: cargo run server",
|
||||
"command": "cargo",
|
||||
"args": ["run", "--bin", "server"],
|
||||
"cwd": "$ZED_WORKTREE_ROOT/chusql-storage",
|
||||
"reveal": "always",
|
||||
"hide": "never",
|
||||
"save": "all",
|
||||
},
|
||||
]
|
||||
@@ -55,4 +55,4 @@
|
||||
| `JOIN` + `WHERE` + `ORDER BY` + `LIMIT 10` | 10 | 164.1 ms | **14.2 ms** | **11.6x** |
|
||||
| 单表 `WHERE age > 90`(对照组) | 45 | 0.175 ms | 0.290 ms | 0.6x |
|
||||
|
||||
> 最近更新:2026-09-22(绝对值受机器状态影响,倍率更稳定;同机同法可复现)
|
||||
> 最近更新:2026-09-22
|
||||
@@ -18,11 +18,18 @@ library
|
||||
ChuSQL.Engine
|
||||
ChuSQL.Model
|
||||
ChuSQL.Semantic
|
||||
ChuSQL.Storage
|
||||
ChuSQL.Storage.IPC
|
||||
ChuSQL.Storage.Memory
|
||||
ChuSQL.Syntax.AST
|
||||
ChuSQL.Syntax.Parser
|
||||
build-depends: base >= 4.14 && < 5
|
||||
, aeson
|
||||
, bytestring
|
||||
, megaparsec
|
||||
, parser-combinators
|
||||
, scientific
|
||||
, text
|
||||
default-language: Haskell2010
|
||||
ghc-options: -Wall
|
||||
|
||||
@@ -40,6 +47,9 @@ test-suite chusql-engine-test
|
||||
hs-source-dirs: test
|
||||
build-depends: base
|
||||
, chusql-engine
|
||||
, directory
|
||||
, filepath
|
||||
, hspec
|
||||
, process
|
||||
default-language: Haskell2010
|
||||
ghc-options: -Wall
|
||||
@@ -1,25 +1,41 @@
|
||||
module ChuSQL.Algebra.Eval (evalRelOp) where
|
||||
module ChuSQL.Algebra.Eval (evalRelOp, evalRelOpM) where
|
||||
|
||||
import ChuSQL.Algebra.Expr (evalCondForRow)
|
||||
import ChuSQL.Algebra.Op
|
||||
import ChuSQL.Algebra.Sort (sortRows)
|
||||
import ChuSQL.Model
|
||||
import ChuSQL.Storage (MonadStorage (..))
|
||||
import ChuSQL.Syntax.AST (Expr (..))
|
||||
import Control.Monad (filterM)
|
||||
|
||||
-- 投影
|
||||
-- 执行:按关系算子树真正算出结果行(Scan 阶段会给列名加上别名前缀)。
|
||||
|
||||
-- 投影:只保留清单里的列;哨兵 "*" 表示全部列。
|
||||
project :: [String] -> Row -> Row
|
||||
project ["*"] row = row
|
||||
project cs row = [(c, v) | (c, v) <- row, c `elem` cs]
|
||||
|
||||
-- 执行关系代数运算
|
||||
-- 给一行里的每个列名加上别名前缀(如 "u.")。
|
||||
addPrefix :: String -> Row -> Row
|
||||
addPrefix prefix = map (\(k, v) -> (prefix ++ k, v))
|
||||
|
||||
evalRelOp :: Database -> RelOp -> Either String [Row]
|
||||
evalRelOp db (Scan mAlias tbl) = do
|
||||
-- 索引查找的约定:主键列名是 "id"(优化器只在这种情况下才把过滤改写成 Lookup)。
|
||||
keyCondition :: Int -> Expr
|
||||
keyCondition k = Eq (Col "id") (LitInt k)
|
||||
|
||||
-- 扫描一张表;有别名就给每个列名加上前缀,JOIN 两边同名列靠它区分。
|
||||
evalScan :: Database -> Maybe String -> String -> Either String [Row]
|
||||
evalScan db mAlias tbl = do
|
||||
table <- lookupTable db tbl
|
||||
let prefix = maybe "" (++ ".") mAlias
|
||||
Right (map (addPrefix prefix) (tableRows table))
|
||||
|
||||
-- 执行关系代数运算:Scan / Lookup / Filter / Project / Sort / Limit / Join 各一支。
|
||||
evalRelOp :: Database -> RelOp -> Either String [Row]
|
||||
evalRelOp db (Scan mAlias tbl) = evalScan db mAlias tbl
|
||||
evalRelOp db (Lookup tbl k) = do
|
||||
rows <- evalScan db Nothing tbl
|
||||
filterM (evalCondForRow (keyCondition k)) rows
|
||||
evalRelOp db (Filter e op) = do
|
||||
rows <- evalRelOp db op
|
||||
filterM (evalCondForRow e) rows
|
||||
@@ -37,3 +53,35 @@ evalRelOp db (Join left right cond) = do
|
||||
rrows <- evalRelOp db right
|
||||
let cross = [l ++ r | l <- lrows, r <- rrows] -- 笛卡尔积
|
||||
filterM (evalCondForRow cond) cross
|
||||
|
||||
-- | Monadic 版本:Lookup 直接问存储要一行(走索引),其余算子照原样递归。
|
||||
evalRelOpM :: (MonadStorage m) => Database -> RelOp -> m (Either String [Row])
|
||||
evalRelOpM _ (Lookup t k) = do
|
||||
result <- lookupByKey t k
|
||||
pure $ case result of
|
||||
Left e -> Left e
|
||||
Right Nothing -> Right []
|
||||
Right (Just r) -> Right [r]
|
||||
evalRelOpM db (Scan a t) = pure (evalScan db a t)
|
||||
evalRelOpM db (Filter p x) = do
|
||||
result <- evalRelOpM db x
|
||||
pure $ do
|
||||
rows <- result
|
||||
filterM (evalCondForRow p) rows
|
||||
evalRelOpM db (Project cols x) = do
|
||||
result <- evalRelOpM db x
|
||||
pure (map (project cols) <$> result)
|
||||
evalRelOpM db (Sort specs x) = do
|
||||
result <- evalRelOpM db x
|
||||
pure (sortRows specs <$> result)
|
||||
evalRelOpM db (Limit n x) = do
|
||||
result <- evalRelOpM db x
|
||||
pure (take n <$> result)
|
||||
evalRelOpM db (Join l r c) = do
|
||||
ls <- evalRelOpM db l
|
||||
rs <- evalRelOpM db r
|
||||
pure $ do
|
||||
leftRows <- ls
|
||||
rightRows <- rs
|
||||
let cross = [x ++ y | x <- leftRows, y <- rightRows]
|
||||
filterM (evalCondForRow c) cross
|
||||
@@ -4,6 +4,8 @@ import ChuSQL.Model
|
||||
import ChuSQL.Syntax.AST
|
||||
import Control.Monad (join)
|
||||
|
||||
-- 表达式求值:在一行(列名 → 值)上算出单个值;另外提供"静态收集表达式里用到哪些列"的工具。
|
||||
|
||||
-- 表达式求值
|
||||
evalExpr :: Expr -> Row -> Either String Value
|
||||
evalExpr (Col name) row =
|
||||
@@ -17,7 +19,7 @@ evalExpr (Eq a b) row = liftBinOp eqOp a b row
|
||||
evalExpr (And a b) row = liftBinOp (boolOp (&&)) a b row
|
||||
evalExpr (Or a b) row = liftBinOp (boolOp (||)) a b row
|
||||
|
||||
-- | 收集表达式里用到的列名
|
||||
-- 收集表达式里用到的列名
|
||||
colsInExpr :: Expr -> [String]
|
||||
colsInExpr (Col c) = [c]
|
||||
colsInExpr (Gt a b) = colsInExpr a ++ colsInExpr b
|
||||
|
||||
@@ -2,9 +2,12 @@ module ChuSQL.Algebra.Op (RelOp (..), renderPlan) where
|
||||
|
||||
import ChuSQL.Syntax.AST (Expr, SortDir)
|
||||
|
||||
-- 关系代数算子:执行计划用 RelOp 表示;另外提供一个把计划打印成缩进文本的辅助函数。
|
||||
|
||||
-- 关系代数算子
|
||||
data RelOp
|
||||
= Scan (Maybe String) String
|
||||
| Lookup String Int
|
||||
| Filter Expr RelOp
|
||||
| Project [String] RelOp
|
||||
| Sort [(String, SortDir)] RelOp
|
||||
@@ -12,11 +15,12 @@ data RelOp
|
||||
| Join RelOp RelOp Expr
|
||||
deriving (Show, Eq)
|
||||
|
||||
-- | 把算子树画成缩进文本(演示和调试用)
|
||||
-- 把算子树画成缩进文本(演示和调试用)
|
||||
renderPlan :: RelOp -> String
|
||||
renderPlan = go 0
|
||||
where
|
||||
go ind op = case op of
|
||||
Lookup t k -> "Lookup " ++ show t ++ " " ++ show k
|
||||
Scan a t -> pad ++ "Scan " ++ show a ++ " " ++ show t
|
||||
Filter e x -> pad ++ "Filter " ++ show e ++ "\n" ++ go (ind + 1) x
|
||||
Project c x -> pad ++ "Project " ++ show c ++ "\n" ++ go (ind + 1) x
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
module ChuSQL.Algebra.Optimize (optimize, pushProject) where
|
||||
|
||||
import ChuSQL.Algebra.Expr (colsInExpr, evalExpr)
|
||||
import ChuSQL.Algebra.Op (RelOp (..))
|
||||
import ChuSQL.Model
|
||||
import ChuSQL.Syntax.AST (Expr (..))
|
||||
import ChuSQL.Algebra.Expr (colsInExpr, evalExpr)
|
||||
|
||||
-- 查询优化:谓词下推 + 投影下推 + 常量折叠,反复应用规则直到算子树形状不再变化。
|
||||
|
||||
-- * 谓词下推
|
||||
|
||||
@@ -15,6 +17,7 @@ relOpCols db (Limit _ x) = relOpCols db x
|
||||
relOpCols _ (Project ["*"] _) = ["*"]
|
||||
relOpCols _ (Project cols _) = cols
|
||||
relOpCols db (Join l r _) = relOpCols db l ++ relOpCols db r
|
||||
relOpCols db (Lookup tbl _) = relOpCols db (Scan Nothing tbl)
|
||||
relOpCols db (Scan mAlias tbl) =
|
||||
case lookup tbl db of
|
||||
Nothing -> []
|
||||
@@ -73,10 +76,18 @@ pushOne _ op = op
|
||||
|
||||
-- * 投影下推
|
||||
|
||||
-- | 工具函数
|
||||
-- | 是不是要拿全部列(need 里含哨兵 "*")。
|
||||
needsAll :: [String] -> Bool
|
||||
needsAll = elem allColumns
|
||||
|
||||
-- | 叶子算子(扫描 / 索引查找)的投影下推:需要全部列就原样保留,缺列才套一层 Project
|
||||
pushProjectLeaf :: Database -> [String] -> RelOp -> RelOp
|
||||
pushProjectLeaf db need leaf
|
||||
| needsAll need = leaf
|
||||
| all (`elem` need) (relOpCols db leaf) = leaf
|
||||
| otherwise = Project need leaf
|
||||
|
||||
-- | 投影下推:把"上层需要哪些列"一路往下传,JOIN 两侧各自只保留自己需要的列。
|
||||
pushProject :: Database -> [String] -> RelOp -> RelOp
|
||||
pushProject db _ (Project cols x) = case pushProject db cols x of
|
||||
Project cols' y | cols' == cols -> Project cols y
|
||||
@@ -93,10 +104,8 @@ pushProject db need (Join l r c)
|
||||
lNeed = if needsAll lCols then ["*"] else [x | x <- tot, x `elem` lCols]
|
||||
rNeed = if needsAll rCols then ["*"] else [x | x <- tot, x `elem` rCols]
|
||||
in Join (pushProject db lNeed l) (pushProject db rNeed r) c
|
||||
pushProject db need scan@(Scan _ _)
|
||||
| needsAll need = scan
|
||||
| all (`elem` need) (relOpCols db scan) = scan
|
||||
| otherwise = Project need scan
|
||||
pushProject db need leaf@(Scan _ _) = pushProjectLeaf db need leaf
|
||||
pushProject db need leaf@(Lookup _ _) = pushProjectLeaf db need leaf
|
||||
|
||||
-- * 常量折叠
|
||||
|
||||
@@ -123,6 +132,8 @@ foldConstants e = e
|
||||
|
||||
-- | 单节点重写:把 Filter 分派给上面几条规则,另外负责恒真条件删除与恒等投影消除
|
||||
rewriteNode :: Database -> RelOp -> RelOp
|
||||
rewriteNode _ (Filter (Eq (Col k) (LitInt v)) (Scan _ t))
|
||||
| k == "id" = Lookup t v
|
||||
rewriteNode db (Filter p (Join l r c)) = pushJoin db p l r c
|
||||
rewriteNode db (Filter p x) = case foldConstants p of
|
||||
LitBool True -> x
|
||||
|
||||
@@ -3,12 +3,15 @@ module ChuSQL.Algebra.Planner (translate) where
|
||||
import ChuSQL.Algebra.Op
|
||||
import ChuSQL.Syntax.AST
|
||||
|
||||
-- 内部查询语句转执行计划
|
||||
-- 计划翻译:把 AST 里的 SELECT 翻译成关系代数算子树(只翻译,不做合法性检查)。
|
||||
|
||||
-- 把 FROM 从句转成 Scan / Join 算子。
|
||||
fromToRelOp :: FromClause -> RelOp
|
||||
fromToRelOp (FromTable mAlias tbl) = Scan mAlias tbl
|
||||
fromToRelOp (FromJoin left mAlias tbl cond) =
|
||||
Join (fromToRelOp left) (Scan mAlias tbl) cond
|
||||
|
||||
-- 翻译入口:把 SELECT 翻译成算子树;其它语句类型不支持(返回 Left)。
|
||||
translate :: Query -> Either String RelOp
|
||||
translate q = case q of
|
||||
Select
|
||||
|
||||
@@ -4,20 +4,22 @@ import ChuSQL.Model
|
||||
import ChuSQL.Syntax.AST
|
||||
import Data.List (sortBy)
|
||||
|
||||
-- | 反向排序
|
||||
-- 排序:按 ORDER BY 的列清单逐列比较(支持 ASC / DESC),供执行 Sort 算子时使用。
|
||||
|
||||
-- 反向排序
|
||||
flipOrdering :: Ordering -> Ordering
|
||||
flipOrdering LT = GT
|
||||
flipOrdering GT = LT
|
||||
flipOrdering EQ = EQ
|
||||
|
||||
-- | 定义值的比较规则
|
||||
-- 定义值的比较规则
|
||||
compareValue :: Value -> Value -> Ordering
|
||||
compareValue (VInt a) (VInt b) = compare a b
|
||||
compareValue (VStr a) (VStr b) = compare a b
|
||||
compareValue (VBool a) (VBool b) = compare a b
|
||||
compareValue _ _ = EQ
|
||||
|
||||
-- | 定义行的比较规则
|
||||
-- 定义行的比较规则
|
||||
compareRows :: [(String, SortDir)] -> Row -> Row -> Ordering
|
||||
compareRows [] _ _ = EQ
|
||||
compareRows ((col, dir) : rest) r1 r2 =
|
||||
@@ -31,7 +33,7 @@ compareRows ((col, dir) : rest) r1 r2 =
|
||||
else o
|
||||
_ -> compareRows rest r1 r2
|
||||
|
||||
-- | 将表的每行排序
|
||||
-- 将表的每行排序
|
||||
sortRows :: [(String, SortDir)] -> [Row] -> [Row]
|
||||
sortRows [] rows = rows
|
||||
sortRows spec rows = sortBy (compareRows spec) rows
|
||||
@@ -1,78 +1,100 @@
|
||||
module ChuSQL.Engine (runQuery, rowsOf) where
|
||||
module ChuSQL.Engine (runQuery, runQueryM, rowsOf) where
|
||||
|
||||
import ChuSQL.Algebra.Eval (evalRelOp)
|
||||
import ChuSQL.Algebra.Eval (evalRelOpM)
|
||||
import ChuSQL.Algebra.Expr (evalCondForRow, evalExpr)
|
||||
import ChuSQL.Algebra.Optimize (optimize)
|
||||
import ChuSQL.Algebra.Planner (translate)
|
||||
import ChuSQL.Model
|
||||
import ChuSQL.Semantic (check)
|
||||
import ChuSQL.Storage (MonadStorage (..))
|
||||
import ChuSQL.Storage.Memory (MemoryStorage (runMemoryStorage))
|
||||
import ChuSQL.Syntax.AST
|
||||
import Control.Monad (filterM)
|
||||
|
||||
-- | 带检查的查询入口:先做语义检查,再交给下面的执行
|
||||
-- | 对外入口:签名不变,内部走内存实现。
|
||||
runQuery :: Database -> Query -> Either String (Database, [Row])
|
||||
runQuery db q = do
|
||||
_ <- check db q
|
||||
runQueryUnchecked db q
|
||||
(result, db') <- runMemoryStorage (runQueryM q) db
|
||||
rows <- result
|
||||
Right (db', rows)
|
||||
|
||||
-- | 只要结果行(丢掉更新后的数据库),给演示和测试用
|
||||
-- | 只要结果行(丢掉更新后的数据库),给演示和测试用。
|
||||
rowsOf :: Either String (Database, [Row]) -> Either String [Row]
|
||||
rowsOf = fmap snd
|
||||
|
||||
-- 把表整体换掉(调用方已经查过,表一定存在)
|
||||
replaceTable :: String -> Table -> Database -> Database
|
||||
replaceTable name table = map (\(n, t) -> if n == name then (n, table) else (n, t))
|
||||
-- * 泛型入口:走 MonadStorage,未来 IPCStorage 复用同一份代码
|
||||
|
||||
-- 修改表
|
||||
applyUpdates :: [(String, Expr)] -> Row -> Either String Row
|
||||
applyUpdates [] row = Right row
|
||||
applyUpdates ((col, e) : rest) row = do
|
||||
v <- evalExpr e row
|
||||
let row' = setColumn col v row
|
||||
applyUpdates rest row'
|
||||
where
|
||||
setColumn c v r = map (\(k, val) -> if k == c then (k, v) else (k, val)) r
|
||||
-- | 语义检查 + 分发到具体语句。
|
||||
runQueryM :: (MonadStorage m) => Query -> m (Either String [Row])
|
||||
runQueryM q = do
|
||||
db <- snapshot
|
||||
case check db q of
|
||||
Left err -> pure (Left err)
|
||||
Right _ -> runQueryUncheckedM q
|
||||
|
||||
-- 实际执行(调用方请确保已过 check)
|
||||
runQueryUnchecked :: Database -> Query -> Either String (Database, [Row])
|
||||
runQueryUnchecked db q@Select{} = do
|
||||
relOp <- translate q
|
||||
rows <- evalRelOp db (optimize db relOp)
|
||||
Right (db, rows)
|
||||
-- | SELECT:拿快照,跑 translate → optimize → evalRelOp。
|
||||
runQueryUncheckedM :: (MonadStorage m) => Query -> m (Either String [Row])
|
||||
runQueryUncheckedM q@Select{} = do
|
||||
db <- snapshot
|
||||
case translate q of
|
||||
Left e -> pure (Left e)
|
||||
Right relOp -> evalRelOpM db (optimize db relOp)
|
||||
|
||||
-- 处理插入
|
||||
runQueryUnchecked db (Insert tbl cols vals) = do
|
||||
values <- mapM (\e -> evalExpr e []) vals
|
||||
if length cols /= length values
|
||||
then Left "column count does not match value count"
|
||||
else do
|
||||
table <- lookupTable db tbl
|
||||
let newRow = zip cols values
|
||||
Right (replaceTable tbl table{tableRows = tableRows table ++ [newRow]} db, [])
|
||||
-- \| INSERT:求值后追加一行。
|
||||
runQueryUncheckedM (Insert tbl cols vals) =
|
||||
case mapM (\e -> evalExpr e []) vals of
|
||||
Left err -> pure (Left err)
|
||||
Right values
|
||||
| length cols /= length values ->
|
||||
pure (Left "column count does not match value count")
|
||||
| otherwise -> do
|
||||
result <- insert tbl (zip cols values)
|
||||
pure (result >> Right [])
|
||||
|
||||
-- 处理删除
|
||||
runQueryUnchecked db (Delete tbl mWhere) = do
|
||||
table <- lookupTable db tbl
|
||||
rows <- case mWhere of
|
||||
-- \| DELETE:扫表、筛掉命中行、整体写回。
|
||||
runQueryUncheckedM (Delete tbl mWhere) = do
|
||||
rowsResult <- scan tbl
|
||||
case rowsResult of
|
||||
Left err -> pure (Left err)
|
||||
Right rows -> do
|
||||
let kept = case mWhere of
|
||||
Nothing -> Right []
|
||||
Just e -> filterM (shouldKeep e) (tableRows table)
|
||||
Right (replaceTable tbl table{tableRows = rows} db, [])
|
||||
Just e -> filterM (shouldKeep e) rows
|
||||
case kept of
|
||||
Left err -> pure (Left err)
|
||||
Right keptRows -> do
|
||||
result <- replaceAll tbl keptRows
|
||||
pure (result >> Right [])
|
||||
where
|
||||
-- 保留 = 条件不成立
|
||||
-- \| 保留 = 条件不成立。
|
||||
shouldKeep :: Expr -> Row -> Either String Bool
|
||||
shouldKeep e row = not <$> evalCondForRow e row
|
||||
|
||||
-- 处理表更新
|
||||
runQueryUnchecked db (Update tbl assigns mWhere) = do
|
||||
table <- lookupTable db tbl
|
||||
rows <- mapM (updateRow mWhere assigns) (tableRows table)
|
||||
Right (replaceTable tbl table{tableRows = rows} db, [])
|
||||
-- \| UPDATE:扫表、逐行改、整体写回。
|
||||
runQueryUncheckedM (Update tbl assigns mWhere) = do
|
||||
rowsResult <- scan tbl
|
||||
case rowsResult of
|
||||
Left err -> pure (Left err)
|
||||
Right rows -> do
|
||||
let newRows = mapM (updateRow mWhere assigns) rows
|
||||
case newRows of
|
||||
Left err -> pure (Left err)
|
||||
Right rs -> do
|
||||
result <- replaceAll tbl rs
|
||||
pure (result >> Right [])
|
||||
where
|
||||
-- \| 命中条件就套用赋值,否则原样返回。
|
||||
updateRow :: Maybe Expr -> [(String, Expr)] -> Row -> Either String Row
|
||||
updateRow cond asgns row = do
|
||||
keep <- case cond of
|
||||
Nothing -> Right True
|
||||
Just e -> evalCondForRow e row
|
||||
if keep
|
||||
then applyUpdates asgns row
|
||||
else Right row
|
||||
if keep then applyUpdates asgns row else Right row
|
||||
|
||||
-- | 按 assignments 依次求值并覆盖对应列。
|
||||
applyUpdates :: [(String, Expr)] -> Row -> Either String Row
|
||||
applyUpdates [] row = Right row
|
||||
applyUpdates ((col, e) : rest) row = do
|
||||
v <- evalExpr e row
|
||||
let row' = map (\(k, val) -> if k == col then (k, v) else (k, val)) row
|
||||
applyUpdates rest row'
|
||||
@@ -1,5 +1,7 @@
|
||||
module ChuSQL.Model (Column (..), Value (..), Row, Table (..), Database, colNames, colType, lookupTable, allColumns) where
|
||||
|
||||
-- 数据模型:列类型、值、行、表、数据库,外加"按名查表 / 查列类型"这类通用小工具。
|
||||
|
||||
-- 列的类型
|
||||
data Column
|
||||
= TInt
|
||||
@@ -32,14 +34,14 @@ type Database = [(String, Table)]
|
||||
colNames :: Table -> [String]
|
||||
colNames = map fst . tableCols
|
||||
|
||||
-- | 查某一列的类型
|
||||
-- 查某一列的类型
|
||||
colType :: Table -> String -> Maybe Column
|
||||
colType t c = lookup c (tableCols t)
|
||||
|
||||
-- | 按名字查表;表不存在就报错
|
||||
-- 按名字查表;表不存在就报错
|
||||
lookupTable :: Database -> String -> Either String Table
|
||||
lookupTable db t = maybe (Left ("unknown table: " ++ t)) Right (lookup t db)
|
||||
|
||||
-- | 投影列清单里的哨兵值:它表示"全部列,不用挑",不是列名
|
||||
-- 投影列清单里的哨兵值:它表示"全部列,不用挑",不是列名
|
||||
allColumns :: String
|
||||
allColumns = "*"
|
||||
@@ -5,6 +5,8 @@ import ChuSQL.Model
|
||||
import ChuSQL.Syntax.AST
|
||||
import Data.List (intercalate)
|
||||
|
||||
-- 语义检查:执行前的关卡 —— 表和列存不存在、表达式类型对不对、写入的值类型是否匹配。
|
||||
|
||||
-- * 表与列名解析
|
||||
|
||||
-- | 给一批列加上"别名."前缀
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
module ChuSQL.Storage (MonadStorage (..)) where
|
||||
|
||||
import ChuSQL.Model (Database, Row)
|
||||
|
||||
-- 存储层抽象:上层只认 domain 类型,不关心底层是内存还是 IPC。
|
||||
class (Monad m) => MonadStorage m where
|
||||
-- 全表扫描;表不存在返回 Left。
|
||||
scan :: String -> m (Either String [Row])
|
||||
|
||||
-- 追加一行到表尾;表不存在返回 Left。
|
||||
insert :: String -> Row -> m (Either String ())
|
||||
|
||||
-- 用新行列表整体替换一张表。
|
||||
replaceAll :: String -> [Row] -> m (Either String ())
|
||||
|
||||
-- 按主键查一行;命中返回 Just,未命中返回 Nothing。
|
||||
lookupByKey :: String -> Int -> m (Either String (Maybe Row))
|
||||
|
||||
-- 取全库快照,喂给还接收 Database 的语义检查 / 优化器 / evalRelOp。
|
||||
snapshot :: m Database
|
||||
Whitespace-only changes.
@@ -0,0 +1,331 @@
|
||||
{-# LANGUAGE ImportQualifiedPost #-}
|
||||
{-# LANGUAGE OverloadedStrings #-}
|
||||
|
||||
module ChuSQL.Storage.IPC (
|
||||
IPCStorage (..),
|
||||
Request (..),
|
||||
Response (..),
|
||||
SchemaColumn (..),
|
||||
sendRequest,
|
||||
doListTables,
|
||||
doLookupByKey,
|
||||
doInsertKeyed,
|
||||
doDescribeTable,
|
||||
) where
|
||||
|
||||
import ChuSQL.Model
|
||||
import ChuSQL.Storage
|
||||
import Control.Exception (bracket)
|
||||
import Data.Aeson (FromJSON (..), ToJSON (..), eitherDecodeStrict, encode, object, withObject, (.:), (.=))
|
||||
import Data.Aeson qualified as A
|
||||
import Data.Aeson.Key qualified as K
|
||||
import Data.Aeson.KeyMap qualified as KM
|
||||
import Data.Aeson.Types (Parser)
|
||||
import Data.ByteString.Char8 qualified as BSC
|
||||
import Data.ByteString.Lazy qualified as BL
|
||||
import Data.List (nub)
|
||||
import Data.Scientific (floatingOrInteger)
|
||||
import Data.Text qualified as T
|
||||
import System.Environment (lookupEnv)
|
||||
import System.IO
|
||||
|
||||
-- IPC 存储实现:每个 MonadStorage 方法翻译成管道上的一条 JSON 请求。
|
||||
|
||||
-- | IPC 实现:通过命名管道连 Rust 存储进程。
|
||||
newtype IPCStorage a = IPCStorage
|
||||
{ runIPCStorage :: IO a
|
||||
}
|
||||
|
||||
-- | Functor:把 fmap 转发给底下的 IO。
|
||||
instance Functor IPCStorage where
|
||||
fmap f (IPCStorage m) = IPCStorage (fmap f m)
|
||||
|
||||
-- | Applicative:把 pure / <*> 转发给底下的 IO。
|
||||
instance Applicative IPCStorage where
|
||||
pure = IPCStorage . pure
|
||||
IPCStorage mf <*> IPCStorage ma = IPCStorage (mf <*> ma)
|
||||
|
||||
-- | Monad:顺序执行,转发给底下的 IO。
|
||||
instance Monad IPCStorage where
|
||||
IPCStorage m >>= k = IPCStorage $ do
|
||||
a <- m
|
||||
runIPCStorage (k a)
|
||||
|
||||
-- | 管道路径;默认 \\.\pipe\chusql-storage,可用 CHUSQL_PIPE 覆盖。
|
||||
pipePath :: IO String
|
||||
pipePath = do
|
||||
name <- maybe "chusql-storage" id <$> lookupEnv "CHUSQL_PIPE"
|
||||
pure ("\\\\.\\pipe\\" ++ name)
|
||||
|
||||
-- | 打开管道、跑闭包、关闭。
|
||||
withPipe :: (Handle -> IO a) -> IO a
|
||||
withPipe = bracket openPipe hClose
|
||||
where
|
||||
-- \| 二进制读写、不做换行翻译,跟 Rust 侧对齐。
|
||||
openPipe = do
|
||||
path <- pipePath
|
||||
h <- openFile path ReadWriteMode
|
||||
hSetBuffering h LineBuffering
|
||||
hSetNewlineMode h noNewlineTranslation
|
||||
pure h
|
||||
|
||||
-- | 发一条请求,读一条响应。
|
||||
sendRequest :: Request -> IO Response
|
||||
sendRequest req = withPipe $ \h -> do
|
||||
BL.hPutStr h (encode req)
|
||||
BSC.hPutStr h "\n"
|
||||
hFlush h
|
||||
line <- BSC.hGetLine h
|
||||
let cleaned = BSC.dropWhileEnd (== '\r') line
|
||||
case eitherDecodeStrict cleaned of
|
||||
Left err -> pure (RespError ("decode: " ++ err))
|
||||
Right r -> pure r
|
||||
|
||||
-- * JSON 编解码
|
||||
|
||||
-- | 一列的 schema;对应 Rust 侧的 SchemaColumn。
|
||||
data SchemaColumn = SchemaColumn
|
||||
{ scName :: String
|
||||
, scType :: String
|
||||
}
|
||||
deriving (Show, Eq)
|
||||
|
||||
-- | SchemaColumn 编码;Haskell 侧只发 DescribeTable,编码用不上,暂存实现。
|
||||
instance ToJSON SchemaColumn where
|
||||
toJSON (SchemaColumn n t) = object ["name" .= n, "ty" .= t]
|
||||
|
||||
-- | SchemaColumn 解码:从 {"name": "...", "ty": "int"|"str"|"bool"} 读回。
|
||||
instance FromJSON SchemaColumn where
|
||||
parseJSON = withObject "SchemaColumn" $ \o -> do
|
||||
n <- o .: "name"
|
||||
t <- o .: "ty"
|
||||
pure (SchemaColumn n t)
|
||||
|
||||
-- | 请求;对应 Rust 侧带 method tag 的 enum。
|
||||
data Request
|
||||
= ReqPing
|
||||
| ReqScan String
|
||||
| ReqInsert String Row (Maybe Int)
|
||||
| ReqReplaceAll String [Row]
|
||||
| ReqListTables
|
||||
| ReqLookupByKey String Int
|
||||
| ReqDescribeTable String
|
||||
|
||||
-- | 请求编码:method 字段 + 各自参数。
|
||||
instance ToJSON Request where
|
||||
toJSON ReqPing = object ["method" .= ("ping" :: T.Text)]
|
||||
toJSON (ReqScan t) = object ["method" .= ("scan" :: T.Text), "table" .= t]
|
||||
toJSON (ReqInsert t r mk) =
|
||||
object $
|
||||
[ "method" .= ("insert" :: T.Text)
|
||||
, "table" .= t
|
||||
, "row" .= rowToJSON r
|
||||
]
|
||||
++ case mk of
|
||||
Nothing -> []
|
||||
Just k -> ["key" .= k]
|
||||
toJSON (ReqReplaceAll t rs) =
|
||||
object
|
||||
[ "method" .= ("replace_all" :: T.Text)
|
||||
, "table" .= t
|
||||
, "rows" .= map rowToJSON rs
|
||||
]
|
||||
toJSON ReqListTables = object ["method" .= ("list_tables" :: T.Text)]
|
||||
toJSON (ReqLookupByKey t k) =
|
||||
object
|
||||
[ "method" .= ("lookup_by_index" :: T.Text)
|
||||
, "table" .= t
|
||||
, "key" .= k
|
||||
]
|
||||
toJSON (ReqDescribeTable t) =
|
||||
object
|
||||
[ "method" .= ("describe_table" :: T.Text)
|
||||
, "table" .= t
|
||||
]
|
||||
|
||||
-- | 响应;对应 Rust 侧带 status tag 的 enum。
|
||||
data Response
|
||||
= RespPong
|
||||
| RespRows [Row]
|
||||
| RespTables [String]
|
||||
| RespSchema [SchemaColumn]
|
||||
| RespOk
|
||||
| RespError String
|
||||
|
||||
-- | 响应解码:按 status 字段分派。
|
||||
instance FromJSON Response where
|
||||
parseJSON = withObject "Response" $ \o -> do
|
||||
status <- o .: "status"
|
||||
case status :: T.Text of
|
||||
"pong" -> pure RespPong
|
||||
"ok" -> pure RespOk
|
||||
"rows" -> RespRows <$> (o .: "rows" >>= mapM rowFromJSON)
|
||||
"tables" -> RespTables <$> o .: "tables"
|
||||
"schema" -> RespSchema <$> o .: "columns"
|
||||
"error" -> RespError <$> o .: "message"
|
||||
other -> fail ("unknown status: " ++ T.unpack other)
|
||||
|
||||
-- | Value 编码成 JSON 的 number / string / bool。
|
||||
valueToJSON :: Value -> A.Value
|
||||
valueToJSON (VInt n) = A.Number (fromIntegral n)
|
||||
valueToJSON (VStr s) = A.String (T.pack s)
|
||||
valueToJSON (VBool b) = A.Bool b
|
||||
|
||||
-- | JSON 解回 Value;number 只接受整数。
|
||||
valueFromJSON :: A.Value -> Parser Value
|
||||
valueFromJSON (A.Number n) =
|
||||
case floatingOrInteger n :: Either Double Integer of
|
||||
Right i -> pure (VInt (fromIntegral i))
|
||||
Left _ -> fail "non-integer number"
|
||||
valueFromJSON (A.String s) = pure (VStr (T.unpack s))
|
||||
valueFromJSON (A.Bool b) = pure (VBool b)
|
||||
valueFromJSON _ = fail "unsupported value type"
|
||||
|
||||
-- | 一行编成 JSON object。
|
||||
rowToJSON :: Row -> A.Value
|
||||
rowToJSON r =
|
||||
A.Object (KM.fromList [(K.fromString k, valueToJSON v) | (k, v) <- r])
|
||||
|
||||
-- | JSON object 解成一行。
|
||||
rowFromJSON :: A.Value -> Parser Row
|
||||
rowFromJSON (A.Object o) = mapM toPair (KM.toList o)
|
||||
where
|
||||
-- \| 解一个键值对。
|
||||
toPair (k, v) = do
|
||||
val <- valueFromJSON v
|
||||
pure (K.toString k, val)
|
||||
rowFromJSON _ = fail "row must be a JSON object"
|
||||
|
||||
-- * 内部请求封装
|
||||
|
||||
-- | 发 Scan 并把结果取成 Either。
|
||||
doScan :: String -> IO (Either String [Row])
|
||||
doScan t = do
|
||||
resp <- sendRequest (ReqScan t)
|
||||
pure $ case resp of
|
||||
RespRows rows -> Right rows
|
||||
RespError e -> Left e
|
||||
_ -> Left "unexpected response to scan"
|
||||
|
||||
-- | 发 Insert(不带索引键)。
|
||||
doInsert :: String -> Row -> IO (Either String ())
|
||||
doInsert t r = do
|
||||
resp <- sendRequest (ReqInsert t r Nothing)
|
||||
pure $ case resp of
|
||||
RespOk -> Right ()
|
||||
RespError e -> Left e
|
||||
_ -> Left "unexpected response to insert"
|
||||
|
||||
-- | 发 Insert(带索引键)。
|
||||
doInsertKeyed :: String -> Row -> Int -> IO (Either String ())
|
||||
doInsertKeyed t r k = do
|
||||
resp <- sendRequest (ReqInsert t r (Just k))
|
||||
pure $ case resp of
|
||||
RespOk -> Right ()
|
||||
RespError e -> Left e
|
||||
_ -> Left "unexpected response to insert"
|
||||
|
||||
-- | 发 ReplaceAll。
|
||||
doReplaceAll :: String -> [Row] -> IO (Either String ())
|
||||
doReplaceAll t rs = do
|
||||
resp <- sendRequest (ReqReplaceAll t rs)
|
||||
pure $ case resp of
|
||||
RespOk -> Right ()
|
||||
RespError e -> Left e
|
||||
_ -> Left "unexpected response to replace_all"
|
||||
|
||||
-- | 发 ListTables。
|
||||
doListTables :: IO (Either String [String])
|
||||
doListTables = do
|
||||
resp <- sendRequest ReqListTables
|
||||
pure $ case resp of
|
||||
RespTables ts -> Right ts
|
||||
RespError e -> Left e
|
||||
_ -> Left "unexpected response to list_tables"
|
||||
|
||||
-- | 发 LookupByIndex。
|
||||
doLookupByKey :: String -> Int -> IO (Either String (Maybe Row))
|
||||
doLookupByKey t k = do
|
||||
resp <- sendRequest (ReqLookupByKey t k)
|
||||
pure $ case resp of
|
||||
RespRows [] -> Right Nothing
|
||||
RespRows (r : _) -> Right (Just r)
|
||||
RespError e -> Left e
|
||||
_ -> Left "unexpected response to lookup_by_index"
|
||||
|
||||
-- | 发 DescribeTable。
|
||||
doDescribeTable :: String -> IO (Either String [SchemaColumn])
|
||||
doDescribeTable t = do
|
||||
resp <- sendRequest (ReqDescribeTable t)
|
||||
pure $ case resp of
|
||||
RespSchema cols -> Right cols
|
||||
RespError e -> Left e
|
||||
_ -> Left "unexpected response to describe_table"
|
||||
|
||||
-- | 把 Rust 的 schema 列转成 Model 的 Column;未知类型降级为 TStr。
|
||||
schemaToColumns :: [SchemaColumn] -> [(String, Column)]
|
||||
schemaToColumns = map go
|
||||
where
|
||||
-- \| 按 ty 字符串映射成 Column 构造子。
|
||||
go (SchemaColumn n "int") = (n, TInt)
|
||||
go (SchemaColumn n "str") = (n, TStr)
|
||||
go (SchemaColumn n "bool") = (n, TBool)
|
||||
go (SchemaColumn n _) = (n, TStr)
|
||||
|
||||
-- | 从若干行推断列名与类型;catalog 拿不到时的兜底。
|
||||
inferColumns :: [Row] -> [(String, Column)]
|
||||
inferColumns rows = [(n, inferType n) | n <- names]
|
||||
where
|
||||
-- \| 全部出现过的列名。
|
||||
names = nub (concatMap (map fst) rows)
|
||||
|
||||
-- \| 用第一个出现的值推断列类型;找不到就给 TStr。
|
||||
inferType n = case [v | r <- rows, Just v <- [lookup n r]] of
|
||||
(VInt _ : _) -> TInt
|
||||
(VStr _ : _) -> TStr
|
||||
(VBool _ : _) -> TBool
|
||||
[] -> TStr
|
||||
|
||||
-- | 先 describe 拿列,再 scan 拿行;describe 拿不到时退回扫行推断。
|
||||
loadTable :: String -> IO Table
|
||||
loadTable t = do
|
||||
schemaR <- doDescribeTable t
|
||||
case schemaR of
|
||||
Right cols -> do
|
||||
rowsR <- doScan t
|
||||
case rowsR of
|
||||
Right rows -> pure (Table t (schemaToColumns cols) rows)
|
||||
Left _ -> pure (Table t (schemaToColumns cols) [])
|
||||
Left _ -> do
|
||||
rowsR <- doScan t
|
||||
case rowsR of
|
||||
Right rows -> pure (Table t (inferColumns rows) rows)
|
||||
Left _ -> pure (Table t [] [])
|
||||
|
||||
-- * MonadStorage 实例
|
||||
|
||||
-- | IPC 实现:所有方法都走管道。
|
||||
instance MonadStorage IPCStorage where
|
||||
-- \| 发 Scan。
|
||||
scan t = IPCStorage (doScan t)
|
||||
|
||||
-- \| 发 Insert,不带索引键。
|
||||
insert t r = IPCStorage (doInsert t r)
|
||||
|
||||
-- \| 发 ReplaceAll。
|
||||
replaceAll t rs = IPCStorage (doReplaceAll t rs)
|
||||
|
||||
-- \| 发 LookupByIndex。
|
||||
lookupByKey t k = IPCStorage (doLookupByKey t k)
|
||||
|
||||
-- \| ListTables + 逐表 (describe, scan),拼成 Database。
|
||||
snapshot = IPCStorage $ do
|
||||
result <- doListTables
|
||||
case result of
|
||||
Left _ -> pure []
|
||||
Right ts -> mapM loadDbEntry ts
|
||||
where
|
||||
-- \| 加载一个 (表名, 表) 对。
|
||||
loadDbEntry t = do
|
||||
tbl <- loadTable t
|
||||
pure (t, tbl)
|
||||
@@ -0,0 +1,66 @@
|
||||
module ChuSQL.Storage.Memory (MemoryStorage (runMemoryStorage)) where
|
||||
|
||||
import ChuSQL.Model
|
||||
import ChuSQL.Storage
|
||||
|
||||
-- | 内存实现:本质是 Database 上的 State,错误通道是 Either String。
|
||||
newtype MemoryStorage a = MemoryStorage {runMemoryStorage :: Database -> Either String (a, Database)}
|
||||
|
||||
-- | Functor:把 fmap 转发给底下的 State。
|
||||
instance Functor MemoryStorage where
|
||||
fmap f (MemoryStorage m) = MemoryStorage $ \db -> do
|
||||
(a, db') <- m db
|
||||
Right (f a, db')
|
||||
|
||||
-- | Applicative:转发 pure / <*>。
|
||||
instance Applicative MemoryStorage where
|
||||
pure x = MemoryStorage $ \db -> Right (x, db)
|
||||
MemoryStorage mf <*> MemoryStorage ma = MemoryStorage $ \db -> do
|
||||
(f, db1) <- mf db
|
||||
(a, db2) <- ma db1
|
||||
Right (f a, db2)
|
||||
|
||||
-- | Monad:顺序执行,把动作转发给底下的 State。
|
||||
instance Monad MemoryStorage where
|
||||
MemoryStorage m >>= k = MemoryStorage $ \db -> do
|
||||
(a, db') <- m db
|
||||
runMemoryStorage (k a) db'
|
||||
|
||||
-- | MonadStorage 实例:内存里直接改 Database。
|
||||
instance MonadStorage MemoryStorage where
|
||||
-- \| 按名查表,返回所有行。
|
||||
scan t = MemoryStorage $ \db -> do
|
||||
tbl <- lookupTable db t
|
||||
Right (Right (tableRows tbl), db)
|
||||
|
||||
-- \| 追加一行到表尾。
|
||||
insert t r = MemoryStorage $ \db -> do
|
||||
tbl <- lookupTable db t
|
||||
Right (Right (), replaceTable t tbl{tableRows = tableRows tbl ++ [r]} db)
|
||||
|
||||
-- \| 用新行列表替换整表内容。
|
||||
replaceAll t rows = MemoryStorage $ \db -> do
|
||||
tbl <- lookupTable db t
|
||||
Right (Right (), replaceTable t tbl{tableRows = rows} db)
|
||||
|
||||
-- \| 找 id 列等于 k 的第一行。
|
||||
lookupByKey t k = MemoryStorage $ \db -> do
|
||||
tbl <- lookupTable db t
|
||||
Right (Right (findRow tbl k), db)
|
||||
|
||||
-- \| 原样返回当前数据库。
|
||||
snapshot = MemoryStorage $ \db -> Right (db, db)
|
||||
|
||||
-- | 按 id 列匹配一个整数。
|
||||
findRow :: Table -> Int -> Maybe Row
|
||||
findRow tbl k = case filter matches (tableRows tbl) of
|
||||
(r : _) -> Just r
|
||||
[] -> Nothing
|
||||
where
|
||||
-- \| 这行的 id 列是否等于 k。
|
||||
matches r = lookup "id" r == Just (VInt k)
|
||||
|
||||
-- | 用给定表替换同名表;不存在就原样返回。
|
||||
replaceTable :: String -> Table -> Database -> Database
|
||||
replaceTable name table =
|
||||
map (\(n, t) -> if n == name then (n, table) else (n, t))
|
||||
@@ -1,5 +1,7 @@
|
||||
module ChuSQL.Syntax.AST (Query (..), FromClause (..), Expr (..), SortDir (..), makeSelect) where
|
||||
|
||||
-- SQL 语法树:四类语句(SELECT / INSERT / DELETE / UPDATE)+ 表达式、FROM 从句、排序方向。
|
||||
|
||||
-- 查询语句
|
||||
data Query
|
||||
= Select
|
||||
@@ -14,7 +16,7 @@ data Query
|
||||
| Update String [(String, Expr)] (Maybe Expr)
|
||||
deriving (Show, Eq)
|
||||
|
||||
-- 从句
|
||||
-- FROM 从句:单表或 JOIN。
|
||||
data FromClause
|
||||
= FromTable (Maybe String) String
|
||||
| FromJoin FromClause (Maybe String) String Expr
|
||||
@@ -33,11 +35,13 @@ data Expr
|
||||
| Or Expr Expr
|
||||
deriving (Show, Eq)
|
||||
|
||||
-- 排序方向
|
||||
data SortDir
|
||||
= Asc
|
||||
| Desc
|
||||
deriving (Show, Eq)
|
||||
|
||||
-- 便捷构造:造一条最简单的 SELECT(给测试和演示用)。
|
||||
makeSelect :: [String] -> String -> Maybe Expr -> Query
|
||||
makeSelect cols tbl w =
|
||||
Select
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
{-# LANGUAGE ImportQualifiedPost #-}
|
||||
|
||||
{- | SQL 语法分析器:把 SQL 文本解析为 'ChuSQL.Syntax.AST.Query'。
|
||||
|
||||
词法层负责空白、关键字、标识符、字面量与符号;语法层按
|
||||
优先级 比较 > AND > OR 自底向上组装表达式。
|
||||
-}
|
||||
module ChuSQL.Syntax.Parser (parseQuery) where
|
||||
|
||||
import ChuSQL.Syntax.AST
|
||||
@@ -17,6 +12,9 @@ import Text.Megaparsec
|
||||
import Text.Megaparsec.Char
|
||||
import Text.Megaparsec.Char.Lexer qualified as Lxr
|
||||
|
||||
-- SQL 解析器:把 SQL 文本解析成 AST。词法层管空白、关键字、标识符、字面量与符号;
|
||||
-- 语法层按优先级 比较 > AND > OR 自底向上组装表达式。
|
||||
|
||||
type Parser = Parsec Void String
|
||||
|
||||
-- * 词法层
|
||||
@@ -29,12 +27,12 @@ sc = Lxr.space space1 empty empty
|
||||
lexeme :: Parser a -> Parser a
|
||||
lexeme = Lxr.lexeme sc
|
||||
|
||||
-- | 匹配一个符号(如 ","、"("),并吃掉其后的空白。
|
||||
symbol :: String -> Parser String
|
||||
symbol = Lxr.symbol sc
|
||||
|
||||
{- | 大小写不敏感的关键字;其后不能紧跟标识符字符,避免把 @selection@ 读成 @select@。
|
||||
整体用 'try' 包住:否则 @ORDER@ 会被 @keyword "or"@ 吃掉前缀 @OR@ 再报错,导致无法回溯。
|
||||
-}
|
||||
-- | 大小写不敏感的关键字;其后不能紧跟标识符字符,避免把 @selection@ 读成 @select@。
|
||||
-- 整体用 'try' 包住:否则 @ORDER@ 会被 @keyword "or"@ 吃掉前缀 @OR@ 再报错,导致无法回溯。
|
||||
keyword :: String -> Parser ()
|
||||
keyword k = lexeme $ try $ do
|
||||
_ <- string' k
|
||||
@@ -47,6 +45,7 @@ identifier = lexeme $ do
|
||||
rest <- many (satisfy isIdentChar)
|
||||
return (first : rest)
|
||||
|
||||
-- | 标识符允许的字符:字母、数字或下划线。
|
||||
isIdentChar :: Char -> Bool
|
||||
isIdentChar ch = isAlphaNum ch || ch == '_'
|
||||
|
||||
@@ -62,9 +61,8 @@ stringLit = lexeme $ do
|
||||
_ <- char '\''
|
||||
return s
|
||||
|
||||
{- | 字符串内的一个字符:@''@ 折叠成一个单引号,其余字符(含反斜杠)原样保留。
|
||||
两个分支都不会在失败时消耗输入,因此可以安全地放进 'many'。
|
||||
-}
|
||||
-- | 字符串内的一个字符:@''@ 折叠成一个单引号,其余字符(含反斜杠)原样保留。
|
||||
-- 两个分支都不会在失败时消耗输入,因此可以安全地放进 'many'。
|
||||
stringChar :: Parser Char
|
||||
stringChar =
|
||||
choice
|
||||
@@ -95,9 +93,8 @@ qualifiedName = do
|
||||
expr :: Parser Expr
|
||||
expr = makeExprParser atom operatorTable
|
||||
|
||||
{- | 运算符表。注意 'makeExprParser' 要求各层 **按优先级从高到低** 排列:
|
||||
比较运算符绑定最紧,'AND' 次之,'OR' 最松。
|
||||
-}
|
||||
-- | 运算符表。注意 'makeExprParser' 要求各层按优先级从高到低排列:
|
||||
-- 比较运算符绑定最紧,'AND' 次之,'OR' 最松。
|
||||
operatorTable :: [[Operator Parser Expr]]
|
||||
operatorTable =
|
||||
[
|
||||
@@ -128,12 +125,14 @@ orderItem = do
|
||||
dir <- sortDir
|
||||
return (col, dir)
|
||||
|
||||
-- | @ORDER BY col [ASC|DESC], ...@。
|
||||
orderByClause :: Parser [(String, SortDir)]
|
||||
orderByClause = do
|
||||
keyword "order"
|
||||
keyword "by"
|
||||
sepBy1 orderItem (symbol ",")
|
||||
|
||||
-- | @col = expr@,用于 UPDATE 的 SET 子句。
|
||||
assignment :: Parser (String, Expr)
|
||||
assignment = do
|
||||
col <- identifier
|
||||
@@ -141,7 +140,7 @@ assignment = do
|
||||
e <- expr
|
||||
return (col, e)
|
||||
|
||||
-- | 从句语法
|
||||
-- * 从句层
|
||||
|
||||
-- | SQL 保留字,不能用作表别名
|
||||
reservedWords :: [String]
|
||||
@@ -166,21 +165,22 @@ reservedWords =
|
||||
, "set"
|
||||
]
|
||||
|
||||
{- | 表别名。别名不能是保留字:否则 @FROM users WHERE age > 18@ 会把 @WHERE@
|
||||
当成 @users@ 的别名吃掉,后面真正的 WHERE 子句就再也解析不到。
|
||||
用 'try' 包住,一旦命中保留字就整体回溯,让 'optional' 正常返回 'Nothing'。
|
||||
-}
|
||||
-- | 表别名。别名不能是保留字:否则 @FROM users WHERE age > 18@ 会把 @WHERE@
|
||||
-- 当成 @users@ 的别名吃掉,后面真正的 WHERE 子句就再也解析不到。
|
||||
-- 用 'try' 包住,一旦命中保留字就整体回溯,让 'optional' 正常返回 'Nothing'。
|
||||
aliasName :: Parser String
|
||||
aliasName = try $ do
|
||||
name <- identifier
|
||||
if map toLower name `elem` reservedWords then empty else return name
|
||||
|
||||
-- | @表名 [别名]@。
|
||||
tableRef :: Parser (Maybe String, String)
|
||||
tableRef = do
|
||||
tbl <- identifier
|
||||
mAlias <- optional aliasName
|
||||
return (mAlias, tbl)
|
||||
|
||||
-- | @JOIN 表 [别名] ON 条件@。
|
||||
joinClause :: Parser (Maybe String, String, Expr)
|
||||
joinClause = do
|
||||
keyword "join"
|
||||
@@ -189,6 +189,7 @@ joinClause = do
|
||||
cond <- expr
|
||||
return (mAlias, tbl, cond)
|
||||
|
||||
-- | FROM 从句:一张表 + 任意多个 JOIN。
|
||||
fromClause :: Parser FromClause
|
||||
fromClause = do
|
||||
(mAlias, tbl) <- tableRef
|
||||
|
||||
+222
-51
@@ -4,15 +4,24 @@ import ChuSQL.Algebra.Eval (evalRelOp)
|
||||
import ChuSQL.Algebra.Op (RelOp (..), renderPlan)
|
||||
import ChuSQL.Algebra.Optimize (optimize, pushProject)
|
||||
import ChuSQL.Algebra.Planner (translate)
|
||||
import ChuSQL.Model
|
||||
import ChuSQL.Syntax.AST
|
||||
import ChuSQL.Engine
|
||||
import ChuSQL.Model
|
||||
import ChuSQL.Storage (MonadStorage (..))
|
||||
import ChuSQL.Storage.IPC (IPCStorage (runIPCStorage), Request (ReqPing), Response (RespPong), doListTables, sendRequest)
|
||||
import ChuSQL.Syntax.AST
|
||||
import ChuSQL.Syntax.Parser
|
||||
import Control.Concurrent (threadDelay)
|
||||
import Control.Exception (IOException, bracket, try)
|
||||
import Data.List (isInfixOf, sortOn)
|
||||
import System.CPUTime (getCPUTime)
|
||||
import System.Directory (createDirectoryIfMissing, doesDirectoryExist, doesFileExist, getTemporaryDirectory, removePathForcibly)
|
||||
import System.Environment (getEnvironment, lookupEnv, setEnv, unsetEnv)
|
||||
import System.Exit (ExitCode (..))
|
||||
import System.FilePath ((</>))
|
||||
import System.IO (Handle)
|
||||
import System.Process (CreateProcess (..), ProcessHandle, StdStream (NoStream), createProcess, proc, terminateProcess, waitForProcess)
|
||||
import Test.Hspec
|
||||
|
||||
-- ============================================================
|
||||
-- Test database
|
||||
-- ============================================================
|
||||
users :: Table
|
||||
users =
|
||||
Table
|
||||
@@ -40,14 +49,8 @@ orders =
|
||||
testDB :: Database
|
||||
testDB = [("users", users), ("orders", orders)]
|
||||
|
||||
-- ============================================================
|
||||
-- Main
|
||||
-- ============================================================
|
||||
main :: IO ()
|
||||
main = hspec $ do
|
||||
-- ============================================================
|
||||
-- AST
|
||||
-- ============================================================
|
||||
describe "ChuSQL.Syntax.AST" $ do
|
||||
it "equal Selects are equal" $ do
|
||||
makeSelect ["name"] "users" Nothing
|
||||
@@ -57,9 +60,6 @@ main = hspec $ do
|
||||
makeSelect ["name"] "users" Nothing
|
||||
`shouldNotBe` makeSelect ["age"] "users" Nothing
|
||||
|
||||
-- ============================================================
|
||||
-- Parser
|
||||
-- ============================================================
|
||||
describe "ChuSQL.Syntax.Parser" $ do
|
||||
it "parses a simple SELECT" $ do
|
||||
parseQuery "SELECT name FROM users"
|
||||
@@ -201,9 +201,6 @@ main = hspec $ do
|
||||
parseQuery "SELECT name FROM"
|
||||
`shouldSatisfy` isLeft
|
||||
|
||||
-- ============================================================
|
||||
-- Parser: JOIN
|
||||
-- ============================================================
|
||||
describe "ChuSQL.Syntax.Parser (JOIN)" $ do
|
||||
it "parses a simple JOIN without aliases" $ do
|
||||
parseQuery "SELECT name FROM users JOIN orders ON id = user_id"
|
||||
@@ -244,9 +241,6 @@ main = hspec $ do
|
||||
Right q -> selectCols q `shouldBe` ["u.name", "o.product"]
|
||||
Left err -> expectationFailure err
|
||||
|
||||
-- ============================================================
|
||||
-- Engine: SELECT
|
||||
-- ============================================================
|
||||
describe "ChuSQL.Engine (SELECT)" $ do
|
||||
it "returns all rows without WHERE" $ do
|
||||
rowsOf (runQuery testDB (makeSelect ["*"] "users" Nothing))
|
||||
@@ -301,9 +295,6 @@ main = hspec $ do
|
||||
runQuery testDB (makeSelect ["nope"] "users" Nothing)
|
||||
`shouldSatisfy` isLeft
|
||||
|
||||
-- ============================================================
|
||||
-- Engine: INSERT
|
||||
-- ============================================================
|
||||
describe "ChuSQL.Engine (INSERT)" $ do
|
||||
it "parses a simple INSERT" $ do
|
||||
parseQuery "INSERT INTO users (name, age) VALUES ('Dave', 22)"
|
||||
@@ -350,9 +341,6 @@ main = hspec $ do
|
||||
runQuery testDB (Insert "users" ["name"] [Col "other"])
|
||||
`shouldSatisfy` isLeft
|
||||
|
||||
-- ============================================================
|
||||
-- Engine: DELETE
|
||||
-- ============================================================
|
||||
describe "ChuSQL.Engine (DELETE)" $ do
|
||||
it "parses DELETE with WHERE" $ do
|
||||
parseQuery "DELETE FROM users WHERE age < 18"
|
||||
@@ -400,9 +388,6 @@ main = hspec $ do
|
||||
runQuery testDB (Delete "users" (Just (Gt (Col "unknown") (LitInt 18))))
|
||||
`shouldSatisfy` isLeft
|
||||
|
||||
-- ============================================================
|
||||
-- Engine: UPDATE
|
||||
-- ============================================================
|
||||
describe "ChuSQL.Engine (UPDATE)" $ do
|
||||
it "parses UPDATE with WHERE" $ do
|
||||
parseQuery "UPDATE users SET age = 26 WHERE name = 'Alice'"
|
||||
@@ -492,9 +477,6 @@ main = hspec $ do
|
||||
Right _ -> True
|
||||
Left _ -> True
|
||||
|
||||
-- ============================================================
|
||||
-- Engine: ORDER BY
|
||||
-- ============================================================
|
||||
describe "ChuSQL.Engine (ORDER BY)" $ do
|
||||
it "parses ORDER BY ASC" $ do
|
||||
case parseQuery "SELECT name FROM users ORDER BY age ASC" of
|
||||
@@ -580,9 +562,6 @@ main = hspec $ do
|
||||
}
|
||||
runQuery testDB q `shouldSatisfy` isLeft
|
||||
|
||||
-- ============================================================
|
||||
-- Engine: LIMIT
|
||||
-- ============================================================
|
||||
describe "ChuSQL.Engine (LIMIT)" $ do
|
||||
it "parses LIMIT" $ do
|
||||
case parseQuery "SELECT * FROM users LIMIT 2" of
|
||||
@@ -663,9 +642,6 @@ main = hspec $ do
|
||||
parseQuery "SELECT * FROM users LIMIT -1"
|
||||
`shouldSatisfy` isLeft
|
||||
|
||||
-- ============================================================
|
||||
-- Engine: JOIN
|
||||
-- ============================================================
|
||||
describe "ChuSQL.Engine (JOIN)" $ do
|
||||
it "executes a two-table JOIN with aliases" $ do
|
||||
rowsOf (parseQuery "SELECT u.name, o.product FROM users u JOIN orders o ON u.id = o.user_id" >>= runQuery testDB)
|
||||
@@ -733,9 +709,6 @@ main = hspec $ do
|
||||
)
|
||||
`shouldSatisfy` isLeft
|
||||
|
||||
-- ============================================================
|
||||
-- End-to-end
|
||||
-- ============================================================
|
||||
describe "ChuSQL end-to-end" $ do
|
||||
it "parses and executes a simple query" $ do
|
||||
rowsOf (parseQuery "SELECT name FROM users WHERE age > 18" >>= runQuery testDB)
|
||||
@@ -937,7 +910,8 @@ main = hspec $ do
|
||||
let plan =
|
||||
Project
|
||||
["name"]
|
||||
( Limit 2
|
||||
( Limit
|
||||
2
|
||||
( Sort
|
||||
[("age", Desc)]
|
||||
(Filter (Gt (Col "age") (LitInt 18)) (Scan Nothing "users"))
|
||||
@@ -979,10 +953,12 @@ main = hspec $ do
|
||||
|
||||
it "keeps projection with ORDER BY and LIMIT results identical" $ do
|
||||
sameResultAsUnoptimized "SELECT u.name FROM users u JOIN orders o ON u.id = o.user_id ORDER BY o.product DESC LIMIT 2"
|
||||
it "rewrites Filter id = k into Lookup" $ do
|
||||
fmap planRoot (optimizedPlan "SELECT name FROM users WHERE id = 2")
|
||||
`shouldBe` Right (Lookup "users" 2)
|
||||
it "keeps Lookup result identical to unoptimized" $ do
|
||||
sameResultAsUnoptimized "SELECT name FROM users WHERE id = 2"
|
||||
|
||||
-- ============================================================
|
||||
-- Semantic
|
||||
-- ============================================================
|
||||
describe "ChuSQL.Semantic" $ do
|
||||
it "rejects an unknown column in WHERE" $ do
|
||||
(parseQuery "SELECT name FROM users WHERE nope > 18" >>= runQuery testDB)
|
||||
@@ -1092,9 +1068,6 @@ main = hspec $ do
|
||||
(parseQuery "SELECT * FROM users u JOIN orders o ON u.id = o.user_id" >>= runQuery testDB)
|
||||
`shouldSatisfy` isRight
|
||||
|
||||
-- ============================================================
|
||||
-- Op
|
||||
-- ============================================================
|
||||
describe "ChuSQL.Algebra.Op" $ do
|
||||
it "renders a plan as indented text" $ do
|
||||
case parseQuery "SELECT name FROM users WHERE age > 18" >>= translate of
|
||||
@@ -1125,12 +1098,203 @@ main = hspec $ do
|
||||
, " Scan Nothing \"users\""
|
||||
]
|
||||
|
||||
describe "ChuSQL.Storage.IPC" $ do
|
||||
it "scan returns rows with all three value types after insert" $ do
|
||||
withTestServer $ \srv -> withServerEnv srv $ do
|
||||
let row = [("id", VInt 7), ("name", VStr "Alice"), ("flag", VBool True)]
|
||||
runIPCStorage (insert "users" row) `shouldReturn` Right ()
|
||||
rows <- runIPCStorage (scan "users")
|
||||
(map sortRow <$> rows) `shouldBe` Right [sortRow row]
|
||||
|
||||
it "scan on a missing table returns Left" $ do
|
||||
withTestServer $ \srv -> withServerEnv srv $ do
|
||||
result <- runIPCStorage (scan "no_such_table")
|
||||
result `shouldSatisfy` isLeft
|
||||
|
||||
it "scan returns all 20 inserted rows in order" $ do
|
||||
withTestServer $ \srv -> withServerEnv srv $ do
|
||||
mapM_ (\i -> runIPCStorage (insert "many" [("id", VInt i)])) [1 .. 20 :: Int]
|
||||
rows <- runIPCStorage (scan "many")
|
||||
fmap length rows `shouldBe` Right 20
|
||||
fmap (sortOn show . map (lookup "id")) rows
|
||||
`shouldBe` Right (sortOn show (map (Just . VInt) [1 .. 20]))
|
||||
|
||||
it "replaceAll leaves only the new rows" $ do
|
||||
withTestServer $ \srv -> withServerEnv srv $ do
|
||||
runIPCStorage (insert "t" [("id", VInt 1)]) `shouldReturn` Right ()
|
||||
runIPCStorage (replaceAll "t" [[("id", VInt 9)], [("id", VInt 8)]]) `shouldReturn` Right ()
|
||||
rows <- runIPCStorage (scan "t")
|
||||
fmap (sortOn show . map (lookup "id")) rows
|
||||
`shouldBe` Right (sortOn show [Just (VInt 9), Just (VInt 8)])
|
||||
|
||||
it "lookupByKey finds a row inserted with an id over the pipe" $ do
|
||||
withTestServer $ \srv -> withServerEnv srv $ do
|
||||
let row = [("id", VInt 7), ("name", VStr "Zoe")]
|
||||
runIPCStorage (insert "keyed" row) `shouldReturn` Right ()
|
||||
found <- runIPCStorage (lookupByKey "keyed" 7)
|
||||
fmap (fmap sortRow) found `shouldBe` Right (Just (sortRow row))
|
||||
missing <- runIPCStorage (lookupByKey "keyed" 8)
|
||||
missing `shouldBe` Right Nothing
|
||||
|
||||
it "replaceAll rebuilds the index over the pipe" $ do
|
||||
withTestServer $ \srv -> withServerEnv srv $ do
|
||||
runIPCStorage (insert "rb" [("id", VInt 1)]) `shouldReturn` Right ()
|
||||
runIPCStorage (replaceAll "rb" [[("id", VInt 9), ("name", VStr "Zoe")]]) `shouldReturn` Right ()
|
||||
old <- runIPCStorage (lookupByKey "rb" 1)
|
||||
old `shouldBe` Right Nothing
|
||||
new <- runIPCStorage (lookupByKey "rb" 9)
|
||||
fmap (fmap sortRow) new
|
||||
`shouldBe` Right (Just (sortRow [("id", VInt 9), ("name", VStr "Zoe")]))
|
||||
|
||||
it "snapshot lists tables and scans each one to build the database" $ do
|
||||
withTestServer $ \srv -> withServerEnv srv $ do
|
||||
runIPCStorage (insert "a" [("id", VInt 1)]) `shouldReturn` Right ()
|
||||
runIPCStorage (insert "b" [("id", VInt 2)]) `shouldReturn` Right ()
|
||||
db <- runIPCStorage snapshot
|
||||
map fst db `shouldBe` ["a", "b"]
|
||||
map (length . tableRows . snd) db `shouldBe` [1, 1]
|
||||
map (map (lookup "id") . tableRows . snd) db `shouldBe` [[Just (VInt 1)], [Just (VInt 2)]]
|
||||
|
||||
it "doListTables returns the tables that exist" $ do
|
||||
withTestServer $ \srv -> withServerEnv srv $ do
|
||||
runIPCStorage (insert "t1" [("id", VInt 1)]) `shouldReturn` Right ()
|
||||
runIPCStorage (insert "t2" [("id", VInt 2)]) `shouldReturn` Right ()
|
||||
doListTables `shouldReturn` Right ["t1", "t2"]
|
||||
|
||||
it "rows survive a restart on the same data directory" $ do
|
||||
located <- locateServer
|
||||
case located of
|
||||
Left err -> pendingWith err
|
||||
Right bin -> do
|
||||
srv1 <- startServer bin
|
||||
let dir = dataDir srv1
|
||||
withServerEnv srv1 $ runIPCStorage (insert "persist" [("id", VInt 42)]) `shouldReturn` Right ()
|
||||
stopServer srv1
|
||||
srv2 <- startServerAt bin dir
|
||||
rows <- withServerEnv srv2 $ runIPCStorage (scan "persist")
|
||||
stopServer srv2
|
||||
cleanServerDir srv1
|
||||
(map sortRow <$> rows) `shouldBe` Right [sortRow [("id", VInt 42)]]
|
||||
|
||||
it "a missing server yields Left without throwing" $ do
|
||||
withPipeName "chusql-no-such-server" $ do
|
||||
result <- runIPCStorage (scan "users")
|
||||
result `shouldSatisfy` isLeft
|
||||
|
||||
emptyDB :: Database
|
||||
emptyDB = [(n, t{tableRows = []}) | (n, t) <- testDB]
|
||||
|
||||
-- ============================================================
|
||||
-- Helpers
|
||||
-- ============================================================
|
||||
data IPCServer = IPCServer
|
||||
{ pipeName :: String
|
||||
, dataDir :: FilePath
|
||||
, processHandle :: ProcessHandle
|
||||
}
|
||||
|
||||
locateServer :: IO (Either String FilePath)
|
||||
locateServer = do
|
||||
mdir <- firstDir [".." </> "chusql-storage", "chusql-storage"]
|
||||
case mdir of
|
||||
Nothing -> pure (Left "chusql-storage directory not found (run these tests inside the repository)")
|
||||
Just dir -> do
|
||||
built <-
|
||||
try (createProcess (proc "cargo" ["build", "--bin", "server"]){cwd = Just dir, std_out = NoStream, std_err = NoStream}) ::
|
||||
IO (Either IOException (Maybe Handle, Maybe Handle, Maybe Handle, ProcessHandle))
|
||||
case built of
|
||||
Left e -> pure (Left ("cannot run cargo (Rust is required for the IPC tests): " ++ show e))
|
||||
Right (_, _, _, ph) -> do
|
||||
ec <- waitForProcess ph
|
||||
case ec of
|
||||
ExitFailure n -> pure (Left ("cargo build --bin server failed with exit code " ++ show n))
|
||||
ExitSuccess -> do
|
||||
found <- firstExisting [dir </> "target" </> "debug" </> "server.exe", dir </> "target" </> "debug" </> "server"]
|
||||
pure (maybe (Left "cargo build finished but no server executable was found") Right found)
|
||||
where
|
||||
firstDir [] = pure Nothing
|
||||
firstDir (d : ds) = do
|
||||
ok <- doesDirectoryExist d
|
||||
if ok then pure (Just d) else firstDir ds
|
||||
firstExisting [] = pure Nothing
|
||||
firstExisting (p : ps) = do
|
||||
ok <- doesFileExist p
|
||||
if ok then pure (Just p) else firstExisting ps
|
||||
|
||||
startServer :: FilePath -> IO IPCServer
|
||||
startServer bin = do
|
||||
stamp <- show <$> getCPUTime
|
||||
tmp <- getTemporaryDirectory
|
||||
let dir = tmp </> "chusql-hs-IPC" </> stamp
|
||||
createDirectoryIfMissing True dir
|
||||
startServerAt bin dir
|
||||
|
||||
startServerAt :: FilePath -> FilePath -> IO IPCServer
|
||||
startServerAt bin dir = do
|
||||
stamp <- show <$> getCPUTime
|
||||
let name = "chusql-hs-test-" ++ stamp
|
||||
parentEnv <- getEnvironment
|
||||
(_, _, _, ph) <-
|
||||
createProcess
|
||||
(proc bin [])
|
||||
{ env = Just (("CHUSQL_PIPE", name) : ("CHUSQL_DATA_DIR", dir) : parentEnv)
|
||||
, std_out = NoStream
|
||||
, std_err = NoStream
|
||||
}
|
||||
let srv = IPCServer name dir ph
|
||||
waitUntilReady srv 100
|
||||
pure srv
|
||||
|
||||
waitUntilReady :: IPCServer -> Int -> IO ()
|
||||
waitUntilReady srv tries = do
|
||||
probe <- try (withServerEnv srv (sendRequest ReqPing)) :: IO (Either IOException Response)
|
||||
case probe of
|
||||
Right RespPong -> pure ()
|
||||
_ | tries > 0 -> threadDelay 50000 >> waitUntilReady srv (tries - 1)
|
||||
Right _ -> ioError (userError "server is up but ping did not answer pong")
|
||||
Left e -> ioError e
|
||||
|
||||
stopServer :: IPCServer -> IO ()
|
||||
stopServer srv = do
|
||||
_ <- try (terminateProcess (processHandle srv)) :: IO (Either IOException ())
|
||||
_ <- try (waitForProcess (processHandle srv)) :: IO (Either IOException ExitCode)
|
||||
pure ()
|
||||
|
||||
cleanServerDir :: IPCServer -> IO ()
|
||||
cleanServerDir srv = do
|
||||
_ <- try (removePathForcibly (dataDir srv)) :: IO (Either IOException ())
|
||||
pure ()
|
||||
|
||||
withTestServer :: (IPCServer -> IO ()) -> IO ()
|
||||
withTestServer act = do
|
||||
located <- locateServer
|
||||
case located of
|
||||
Left err
|
||||
| "cannot run cargo" `isInfixOf` err -> pendingWith err
|
||||
| otherwise -> expectationFailure err
|
||||
Right bin -> bracket (startServer bin) (\s -> stopServer s >> cleanServerDir s) act
|
||||
|
||||
withServerEnv :: IPCServer -> IO a -> IO a
|
||||
withServerEnv srv act = do
|
||||
oldPipe <- lookupEnv "CHUSQL_PIPE"
|
||||
oldDir <- lookupEnv "CHUSQL_DATA_DIR"
|
||||
setEnv "CHUSQL_PIPE" (pipeName srv)
|
||||
setEnv "CHUSQL_DATA_DIR" (dataDir srv)
|
||||
r <- act
|
||||
restore "CHUSQL_PIPE" oldPipe
|
||||
restore "CHUSQL_DATA_DIR" oldDir
|
||||
pure r
|
||||
where
|
||||
restore k = maybe (unsetEnv k) (setEnv k)
|
||||
|
||||
withPipeName :: String -> IO a -> IO a
|
||||
withPipeName name act = do
|
||||
old <- lookupEnv "CHUSQL_PIPE"
|
||||
setEnv "CHUSQL_PIPE" name
|
||||
r <- act
|
||||
maybe (unsetEnv "CHUSQL_PIPE") (setEnv "CHUSQL_PIPE") old
|
||||
pure r
|
||||
|
||||
sortRow :: Row -> Row
|
||||
sortRow = sortOn fst
|
||||
|
||||
isLeft :: Either a b -> Bool
|
||||
isLeft (Left _) = True
|
||||
isLeft (Right _) = False
|
||||
@@ -1167,6 +1331,7 @@ firstFilterCond (Join l r _) = case firstFilterCond l of
|
||||
Just p -> Just p
|
||||
Nothing -> firstFilterCond r
|
||||
firstFilterCond (Scan _ _) = Nothing
|
||||
firstFilterCond (Lookup _ _) = Nothing
|
||||
|
||||
anyFilter :: RelOp -> Bool
|
||||
anyFilter (Filter _ _) = True
|
||||
@@ -1175,6 +1340,7 @@ anyFilter (Sort _ x) = anyFilter x
|
||||
anyFilter (Limit _ x) = anyFilter x
|
||||
anyFilter (Join l r _) = anyFilter l || anyFilter r
|
||||
anyFilter (Scan _ _) = False
|
||||
anyFilter (Lookup _ _) = False
|
||||
|
||||
stripProjects :: RelOp -> RelOp
|
||||
stripProjects (Project _ x) = stripProjects x
|
||||
@@ -1183,6 +1349,7 @@ stripProjects (Sort spec x) = Sort spec (stripProjects x)
|
||||
stripProjects (Limit n x) = Limit n (stripProjects x)
|
||||
stripProjects (Join l r c) = Join (stripProjects l) (stripProjects r) c
|
||||
stripProjects (Scan a t) = Scan a t
|
||||
stripProjects (Lookup t k) = Lookup t k
|
||||
|
||||
projectedPlan :: String -> Either String RelOp
|
||||
projectedPlan sql = do
|
||||
@@ -1205,3 +1372,7 @@ countFilters _ = 0
|
||||
leftSideFilters :: RelOp -> Maybe Int
|
||||
leftSideFilters (Project _ (Join l _ _)) = Just (countFilters l)
|
||||
leftSideFilters _ = Nothing
|
||||
|
||||
planRoot :: RelOp -> RelOp
|
||||
planRoot (Project _ x) = planRoot x
|
||||
planRoot x = x
|
||||
Whitespace-only changes.
Generated
+326
@@ -0,0 +1,326 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
|
||||
|
||||
[[package]]
|
||||
name = "chusql-storage"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"interprocess",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"toml",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "doctest-file"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2db04e74f0a9a93103b50e90b96024c9b2bdca8bce6a632ec71b88736d3d359"
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||
|
||||
[[package]]
|
||||
name = "errno"
|
||||
version = "0.3.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"r-efi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "interprocess"
|
||||
version = "2.4.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6cb02ffbf25e8ef0f4a95bff054eef75f6d1bf16919474cf2caa63b21997478a"
|
||||
dependencies = [
|
||||
"doctest-file",
|
||||
"libc",
|
||||
"recvmsg",
|
||||
"widestring",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.189"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "r-efi"
|
||||
version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||
|
||||
[[package]]
|
||||
name = "recvmsg"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3edd4d5d42c92f0a659926464d4cce56b562761267ecf0f469d85b7de384175"
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "1.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.151"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_spanned"
|
||||
version = "0.6.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.27.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml"
|
||||
version = "0.8.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_spanned",
|
||||
"toml_datetime",
|
||||
"toml_edit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_datetime"
|
||||
version = "0.6.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_edit"
|
||||
version = "0.22.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"serde",
|
||||
"serde_spanned",
|
||||
"toml_datetime",
|
||||
"toml_write",
|
||||
"winnow",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_write"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.26"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
|
||||
|
||||
[[package]]
|
||||
name = "widestring"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471"
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
version = "0.7.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "chusql-storage"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
interprocess = "2"
|
||||
toml = "0.8"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
@@ -0,0 +1,40 @@
|
||||
# ChuSQL 存储服务配置示例
|
||||
#
|
||||
# 用法:把本文件复制成 chusql-storage.toml(放在启动 server 时的当前目录下),
|
||||
# 或者让环境变量 CHUSQL_CONFIG 指向任意路径的这个文件。
|
||||
#
|
||||
# 每一项都可以删掉:没写的项回退到内置默认值(下面注释里的值就是默认值)。
|
||||
# 优先级:环境变量 > 本文件 > 内置默认值。
|
||||
# 启动时 server 会把"读了哪个文件、每个选项从哪来"打进日志([INFO] 开头那几行)。
|
||||
|
||||
[page]
|
||||
# 页大小(字节):512 ~ 65536 之间的 2 的幂。
|
||||
# 注意:改它会让已有数据文件(.db)读不出来(文件长度对不上页大小会直接报错)。
|
||||
# 环境变量:CHUSQL_PAGE_SIZE
|
||||
size = 4096
|
||||
|
||||
[btree]
|
||||
# B+ 树一个节点最多几个孩子(>= 3,且必须能放进一页:12 + (order-1)*8 + order*8 <= size)。
|
||||
# 注意:改它会让已有索引文件(.idx)对不上文件头里记的值,打开时报错。
|
||||
# 环境变量:CHUSQL_BTREE_ORDER
|
||||
order = 4
|
||||
|
||||
[storage]
|
||||
# 数据目录:一张表一个 <表名>.db,一个索引一个 <表名>.idx。
|
||||
# 环境变量:CHUSQL_DATA_DIR
|
||||
data_dir = "data"
|
||||
|
||||
[server]
|
||||
# 命名管道名(Windows 上实际是 \\.\pipe\<这个名字>)。
|
||||
# 环境变量:CHUSQL_PIPE
|
||||
pipe_name = "chusql-storage"
|
||||
|
||||
[log]
|
||||
# 日志等级:off / error / warn / info / debug。debug 会多打每个请求和连接。
|
||||
# 环境变量:CHUSQL_LOG
|
||||
level = "info"
|
||||
|
||||
# 颜色不用写在文件里,用环境变量控制:CHUSQL_LOG_COLOR = auto(默认,stderr 是终端才着色)
|
||||
# / always / never
|
||||
# 行格式:[等级 本地日期时间] [来源] 消息;来源 core=服务自身、pipe=由管道请求触发
|
||||
# ERROR 加粗红、WARN 加粗橙、DEBUG 加粗不变色、INFO 不加样式
|
||||
@@ -0,0 +1,412 @@
|
||||
//! 命名管道服务:按行读写 JSON。Windows 上管道名映射为 \\.\pipe\<name>。
|
||||
//! 启动时读配置(环境变量 → TOML → 默认值)并把每项来源打进日志。
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::thread;
|
||||
|
||||
use chusql_storage::catalog::Catalog;
|
||||
use chusql_storage::config::{self, Config, Loaded};
|
||||
use chusql_storage::heap::HeapTable;
|
||||
use chusql_storage::log;
|
||||
use chusql_storage::protocol::{Request, Response};
|
||||
use chusql_storage::wal::{Wal, WalOp};
|
||||
use chusql_storage::{log_debug, log_error, log_info, log_warn};
|
||||
use interprocess::local_socket::{
|
||||
prelude::*,
|
||||
GenericNamespaced,
|
||||
ListenerOptions,
|
||||
};
|
||||
use interprocess::TryClone;
|
||||
|
||||
// * 路径
|
||||
|
||||
/// 表名 -> 数据文件路径。
|
||||
fn table_path(cfg: &Config, table: &str) -> PathBuf {
|
||||
cfg.data_dir.join(format!("{}.db", table))
|
||||
}
|
||||
|
||||
/// 表名 -> 索引文件路径。
|
||||
fn index_path(cfg: &Config, table: &str) -> PathBuf {
|
||||
cfg.data_dir.join(format!("{}.idx", table))
|
||||
}
|
||||
|
||||
/// 数据字典文件路径。
|
||||
fn catalog_path(cfg: &Config) -> PathBuf {
|
||||
cfg.data_dir.join("catalog.json")
|
||||
}
|
||||
|
||||
/// WAL 文件路径。
|
||||
fn wal_path(cfg: &Config) -> PathBuf {
|
||||
cfg.data_dir.join("wal.log")
|
||||
}
|
||||
|
||||
// * 表操作
|
||||
|
||||
/// 打开一张表,执行闭包;表不存在就创建。
|
||||
fn with_table<R>(
|
||||
cfg: &Config,
|
||||
table: &str,
|
||||
f: impl FnOnce(&mut HeapTable) -> std::io::Result<R>,
|
||||
) -> std::io::Result<R> {
|
||||
std::fs::create_dir_all(&cfg.data_dir)?;
|
||||
let mut t = HeapTable::open(table_path(cfg, table), cfg.page_size)?;
|
||||
f(&mut t)
|
||||
}
|
||||
|
||||
/// 打开带索引的表,执行闭包。
|
||||
fn with_indexed_table<R>(
|
||||
cfg: &Config,
|
||||
table: &str,
|
||||
f: impl FnOnce(&mut HeapTable) -> std::io::Result<R>,
|
||||
) -> std::io::Result<R> {
|
||||
std::fs::create_dir_all(&cfg.data_dir)?;
|
||||
let mut t = HeapTable::open_indexed(
|
||||
table_path(cfg, table),
|
||||
index_path(cfg, table),
|
||||
cfg.page_size,
|
||||
cfg.btree_order,
|
||||
)?;
|
||||
f(&mut t)
|
||||
}
|
||||
|
||||
/// 表文件是否存在且非空。
|
||||
fn table_exists(cfg: &Config, table: &str) -> bool {
|
||||
let p = table_path(cfg, table);
|
||||
std::fs::metadata(&p).map(|m| m.len() > 0).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// 打开已存在的表;不存在返回 NotFound。
|
||||
fn with_existing_table<R>(
|
||||
cfg: &Config,
|
||||
table: &str,
|
||||
f: impl FnOnce(&mut HeapTable) -> std::io::Result<R>,
|
||||
) -> std::io::Result<R> {
|
||||
if !table_exists(cfg, table) {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::NotFound,
|
||||
format!("unknown table: {}", table),
|
||||
));
|
||||
}
|
||||
let mut t = HeapTable::open(table_path(cfg, table), cfg.page_size)?;
|
||||
f(&mut t)
|
||||
}
|
||||
|
||||
// * 数据字典操作
|
||||
|
||||
/// 读 catalog,按一行数据补列,再写回。
|
||||
fn catalog_add_row(
|
||||
cfg: &Config,
|
||||
table: &str,
|
||||
row: &serde_json::Map<String, serde_json::Value>,
|
||||
) -> std::io::Result<()> {
|
||||
let mut c = Catalog::load(catalog_path(cfg))?;
|
||||
let row: chusql_storage::protocol::Row = row.clone().into_iter().collect();
|
||||
c.ensure_columns(table, &row);
|
||||
c.save(catalog_path(cfg))
|
||||
}
|
||||
|
||||
/// 读 catalog,按多行数据补列,再写回。
|
||||
fn catalog_add_rows(
|
||||
cfg: &Config,
|
||||
table: &str,
|
||||
rows: &[serde_json::Map<String, serde_json::Value>],
|
||||
) -> std::io::Result<()> {
|
||||
let mut c = Catalog::load(catalog_path(cfg))?;
|
||||
for r in rows {
|
||||
let row: chusql_storage::protocol::Row = r.clone().into_iter().collect();
|
||||
c.ensure_columns(table, &row);
|
||||
}
|
||||
c.save(catalog_path(cfg))
|
||||
}
|
||||
|
||||
// * WAL 写路径与恢复
|
||||
|
||||
/// 把一条 WAL 操作真正落到磁盘(堆表 + 索引 + catalog)。
|
||||
fn apply_op(cfg: &Config, op: &WalOp) -> std::io::Result<()> {
|
||||
match op {
|
||||
WalOp::Insert { table, row, key } => {
|
||||
match key {
|
||||
None => with_table(cfg, table, |t| t.insert(row))?,
|
||||
Some(k) => with_indexed_table(cfg, table, |t| t.insert_keyed(row, *k))?,
|
||||
}
|
||||
let map: serde_json::Map<_, _> = row.clone().into_iter().collect();
|
||||
catalog_add_row(cfg, table, &map)?;
|
||||
Ok(())
|
||||
}
|
||||
WalOp::ReplaceAll { table, rows } => {
|
||||
with_indexed_table(cfg, table, |t| t.replace_all(rows))?;
|
||||
let maps: Vec<_> = rows.iter().map(|r| r.clone().into_iter().collect()).collect();
|
||||
catalog_add_rows(cfg, table, &maps)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 写前日志 + 写数据 + 清空 WAL。
|
||||
///
|
||||
/// 顺序:clear(清掉上次已完成的) → append+fsync → apply → clear。
|
||||
/// 若中途进程被杀,重启时 WAL 里能读到那条操作并重放。
|
||||
fn write_via_wal(cfg: &Config, op: &WalOp) -> std::io::Result<()> {
|
||||
let wal = Wal::new(wal_path(cfg));
|
||||
|
||||
// 清掉上一次遗留(正常情况下已经是空的)
|
||||
wal.clear()?;
|
||||
|
||||
// 先写 WAL 并 fsync
|
||||
wal.append(op)?;
|
||||
|
||||
// 再写数据
|
||||
let result = apply_op(cfg, op);
|
||||
|
||||
// 无论 apply 成功还是返回 Err,都清空 WAL:
|
||||
// 失败的操作不应被重放;若中途被杀,这里跑不到,WAL 会留到重启时重放。
|
||||
wal.clear()?;
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// 启动时重放 WAL 里的残留操作。
|
||||
fn recover_wal(cfg: &Config) -> std::io::Result<()> {
|
||||
let wal = Wal::new(wal_path(cfg));
|
||||
match wal.read()? {
|
||||
None => Ok(()),
|
||||
Some(op) => {
|
||||
log_warn!(core, "replaying incomplete WAL entry");
|
||||
apply_op(cfg, &op)?;
|
||||
wal.clear()?;
|
||||
log_info!(core, "WAL replay complete");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// * 请求分发
|
||||
|
||||
/// 把一条请求翻译成一条响应。
|
||||
fn handle_request(cfg: &Config, req: Request) -> Response {
|
||||
match req {
|
||||
Request::Ping => {
|
||||
log_debug!(pipe, "ping");
|
||||
Response::Pong
|
||||
}
|
||||
|
||||
Request::Scan { table } => {
|
||||
log_debug!(pipe, "scan table={}", table);
|
||||
match with_existing_table(cfg, &table, |t| t.scan()) {
|
||||
Ok(rows) => Response::Rows { rows },
|
||||
Err(e) => {
|
||||
log_warn!(pipe, "scan {}: {}", table, e);
|
||||
Response::Error {
|
||||
message: format!("scan {}: {}", table, e),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Request::Insert { table, row, key } => {
|
||||
log_debug!(pipe, "insert table={} key={:?}", table, key);
|
||||
let tname = table.clone();
|
||||
let op = WalOp::Insert { table, row, key };
|
||||
match write_via_wal(cfg, &op) {
|
||||
Ok(()) => Response::Ok,
|
||||
Err(e) => {
|
||||
log_warn!(pipe, "insert {}: {}", tname, e);
|
||||
Response::Error {
|
||||
message: format!("insert {}: {}", tname, e),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Request::LookupByIndex { table, key } => {
|
||||
log_debug!(pipe, "lookup_by_index table={} key={}", table, key);
|
||||
match with_indexed_table(cfg, &table, |t| t.get_by_key(key)) {
|
||||
Ok(Some(row)) => Response::Rows { rows: vec![row] },
|
||||
Ok(None) => Response::Rows { rows: vec![] },
|
||||
Err(e) => {
|
||||
log_warn!(pipe, "lookup_by_index {}: {}", table, e);
|
||||
Response::Error {
|
||||
message: format!("lookup_by_index {}: {}", table, e),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Request::ReplaceAll { table, rows } => {
|
||||
log_debug!(pipe, "replace_all table={} rows={}", table, rows.len());
|
||||
let tname = table.clone();
|
||||
let op = WalOp::ReplaceAll { table, rows };
|
||||
match write_via_wal(cfg, &op) {
|
||||
Ok(()) => Response::Ok,
|
||||
Err(e) => {
|
||||
log_warn!(pipe, "replace_all {}: {}", tname, e);
|
||||
Response::Error {
|
||||
message: format!("replace_all {}: {}", tname, e),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Request::ListTables => {
|
||||
log_debug!(pipe, "list_tables");
|
||||
match list_tables(cfg) {
|
||||
Ok(tables) => Response::Tables { tables },
|
||||
Err(e) => {
|
||||
log_warn!(pipe, "list_tables: {}", e);
|
||||
Response::Error {
|
||||
message: format!("list_tables: {}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Request::DescribeTable { table } => {
|
||||
log_debug!(pipe, "describe_table table={}", table);
|
||||
match describe_table(cfg, &table) {
|
||||
Ok(cols) => Response::Schema { columns: cols },
|
||||
Err(e) => {
|
||||
log_warn!(pipe, "describe_table {}: {}", table, e);
|
||||
Response::Error {
|
||||
message: format!("describe_table {}: {}", table, e),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 列出表名:catalog 记录 + data 目录下的 .db 文件,取并集。
|
||||
fn list_tables(cfg: &Config) -> std::io::Result<Vec<String>> {
|
||||
let mut names = BTreeSet::new();
|
||||
|
||||
let c = Catalog::load(catalog_path(cfg))?;
|
||||
for t in c.table_names() {
|
||||
names.insert(t);
|
||||
}
|
||||
|
||||
let dir = &cfg.data_dir;
|
||||
if dir.exists() {
|
||||
for entry in std::fs::read_dir(dir)? {
|
||||
let entry = entry?;
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
if let Some(stem) = name.strip_suffix(".db") {
|
||||
names.insert(stem.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(names.into_iter().collect())
|
||||
}
|
||||
|
||||
/// 查一张表的 schema。表在 catalog 里没有,但文件存在,返回空列。
|
||||
fn describe_table(
|
||||
cfg: &Config,
|
||||
table: &str,
|
||||
) -> std::io::Result<Vec<chusql_storage::protocol::SchemaColumn>> {
|
||||
let c = Catalog::load(catalog_path(cfg))?;
|
||||
if let Some(schema) = c.describe(table) {
|
||||
return Ok(schema.columns.clone());
|
||||
}
|
||||
if table_exists(cfg, table) {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
Err(std::io::Error::new(
|
||||
std::io::ErrorKind::NotFound,
|
||||
format!("unknown table: {}", table),
|
||||
))
|
||||
}
|
||||
|
||||
// * 连接处理
|
||||
|
||||
/// 处理一条连接:逐行读 JSON 请求,逐行回 JSON 响应。
|
||||
fn handle(cfg: &Config, conn: LocalSocketStream) -> std::io::Result<()> {
|
||||
let mut writer = conn.try_clone()?;
|
||||
let reader = BufReader::new(conn);
|
||||
|
||||
for line in reader.lines() {
|
||||
let line = line?;
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let resp = match serde_json::from_str::<Request>(&line) {
|
||||
Ok(req) => handle_request(cfg, req),
|
||||
Err(e) => {
|
||||
log_warn!(pipe, "bad request line: {}", e);
|
||||
Response::Error {
|
||||
message: format!("parse error: {}", e),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
writeln!(writer, "{}", serde_json::to_string(&resp).unwrap())?;
|
||||
writer.flush()?;
|
||||
}
|
||||
log_debug!(pipe, "connection closed");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// * 主入口
|
||||
|
||||
/// 把"配置从哪来"打进日志。
|
||||
fn log_config(loaded: &Loaded) {
|
||||
match &loaded.config_path {
|
||||
Some(p) => log_info!(core, "config file: {}", p.display()),
|
||||
None => log_info!(core, "config file: none"),
|
||||
}
|
||||
for (name, value, origin) in &loaded.origins {
|
||||
log_info!(core, " {:<17} {} [{}]", name, value, origin.describe());
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> std::io::Result<()> {
|
||||
let loaded = match config::load() {
|
||||
Ok(l) => l,
|
||||
Err(e) => {
|
||||
log_error!(core, "config error: {}", e);
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, e));
|
||||
}
|
||||
};
|
||||
log::init();
|
||||
log::set_level(loaded.config.log_level);
|
||||
|
||||
log_info!(core, "chusql-storage {} starting", env!("CARGO_PKG_VERSION"));
|
||||
log_config(&loaded);
|
||||
|
||||
let cfg = Arc::new(loaded.config);
|
||||
|
||||
// 启动时先重放 WAL,再做其他事
|
||||
if let Err(e) = recover_wal(&cfg) {
|
||||
log_error!(core, "WAL recovery failed: {}", e);
|
||||
return Err(e);
|
||||
}
|
||||
|
||||
let ns_name = cfg
|
||||
.pipe_name
|
||||
.as_str()
|
||||
.to_ns_name::<GenericNamespaced>()
|
||||
.map_err(|e| std::io::Error::other(e.to_string()))?;
|
||||
let listener = ListenerOptions::new().name(ns_name).create_sync()?;
|
||||
|
||||
log_info!(
|
||||
core,
|
||||
"listening on pipe {} (\\\\.\\pipe\\{})",
|
||||
cfg.pipe_name,
|
||||
cfg.pipe_name
|
||||
);
|
||||
|
||||
for conn in listener.incoming() {
|
||||
let conn = conn?;
|
||||
let cfg = Arc::clone(&cfg);
|
||||
thread::spawn(move || {
|
||||
log_debug!(pipe, "connection accepted");
|
||||
if let Err(e) = handle(&cfg, conn) {
|
||||
log_error!(pipe, "connection error: {}", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,470 @@
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::page::{Page, PageFile, PageId};
|
||||
|
||||
// 磁盘版 B+ 树:每个节点占一页,page 0 存文件头(魔数 + 页大小 + order + 根页号)。
|
||||
// 节点页布局(order = 一个节点最多几个孩子,来自配置):
|
||||
// [0] 节点类型:0=叶子,1=内部
|
||||
// [1..3] 键数量 u16
|
||||
// [3..11] 叶子的 next 页号 u64(内部保留)
|
||||
// [12..12+K*8] 键:K = order-1 个 i64
|
||||
// [后面] 叶子:K 个 u64 值;内部:order 个 u64 孩子页号
|
||||
|
||||
/// 文件头放在这一页。
|
||||
const META_PAGE: PageId = 0;
|
||||
/// 文件头魔数。
|
||||
const META_MAGIC: &[u8; 4] = b"CBTR";
|
||||
/// 文件头里各字段的偏移。
|
||||
const OFF_META_PAGE_SIZE: usize = 4;
|
||||
const OFF_META_ORDER: usize = 8;
|
||||
const OFF_META_ROOT: usize = 10;
|
||||
|
||||
/// 节点类型标记。
|
||||
const NODE_LEAF: u8 = 0;
|
||||
const NODE_INTERNAL: u8 = 1;
|
||||
/// 布局偏移。
|
||||
const OFF_TYPE: usize = 0;
|
||||
const OFF_COUNT: usize = 1;
|
||||
const OFF_NEXT: usize = 3;
|
||||
const OFF_KEYS: usize = 12;
|
||||
|
||||
/// 一个 order 的节点占多少字节(内部节点最坏情况:order-1 个键 + order 个孩子)。
|
||||
fn node_bytes(order: usize) -> usize {
|
||||
OFF_KEYS + max_keys(order) * 8 + order * 8
|
||||
}
|
||||
|
||||
/// 一个节点最多几个键。
|
||||
fn max_keys(order: usize) -> usize {
|
||||
order - 1
|
||||
}
|
||||
|
||||
/// 键后面那片区域的起点。
|
||||
fn off_payload(order: usize) -> usize {
|
||||
OFF_KEYS + max_keys(order) * 8
|
||||
}
|
||||
|
||||
/// 这个 order 放得进 size 字节的页吗。
|
||||
pub fn fits_in_page(page_size: usize, order: usize) -> bool {
|
||||
order >= 2 && node_bytes(order) <= page_size
|
||||
}
|
||||
|
||||
/// 给定页大小,order 最大能取多少(解 12 + (order-1)*8 + order*8 <= page_size)。
|
||||
pub fn max_order(page_size: usize) -> usize {
|
||||
page_size.saturating_sub(4) / 16
|
||||
}
|
||||
|
||||
/// 读一个 u8。
|
||||
fn read_u8(p: &Page, off: usize) -> u8 {
|
||||
p.data[off]
|
||||
}
|
||||
|
||||
/// 写一个 u8。
|
||||
fn write_u8(p: &mut Page, off: usize, v: u8) {
|
||||
p.data[off] = v;
|
||||
}
|
||||
|
||||
/// 读一个小端 u16。
|
||||
fn read_u16(p: &Page, off: usize) -> u16 {
|
||||
u16::from_le_bytes([p.data[off], p.data[off + 1]])
|
||||
}
|
||||
|
||||
/// 写一个小端 u16。
|
||||
fn write_u16(p: &mut Page, off: usize, v: u16) {
|
||||
p.data[off..off + 2].copy_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
|
||||
/// 读一个小端 u32。
|
||||
fn read_u32(p: &Page, off: usize) -> u32 {
|
||||
u32::from_le_bytes([p.data[off], p.data[off + 1], p.data[off + 2], p.data[off + 3]])
|
||||
}
|
||||
|
||||
/// 写一个小端 u32。
|
||||
fn write_u32(p: &mut Page, off: usize, v: u32) {
|
||||
p.data[off..off + 4].copy_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
|
||||
/// 读一个小端 u64。
|
||||
fn read_u64(p: &Page, off: usize) -> u64 {
|
||||
let mut b = [0u8; 8];
|
||||
b.copy_from_slice(&p.data[off..off + 8]);
|
||||
u64::from_le_bytes(b)
|
||||
}
|
||||
|
||||
/// 写一个小端 u64。
|
||||
fn write_u64(p: &mut Page, off: usize, v: u64) {
|
||||
p.data[off..off + 8].copy_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
|
||||
/// 写文件头。
|
||||
fn write_meta(page: &mut Page, page_size: usize, order: usize, root: PageId) {
|
||||
page.zero();
|
||||
page.data[0..4].copy_from_slice(META_MAGIC);
|
||||
write_u32(page, OFF_META_PAGE_SIZE, page_size as u32);
|
||||
write_u16(page, OFF_META_ORDER, order as u16);
|
||||
write_u64(page, OFF_META_ROOT, root);
|
||||
}
|
||||
|
||||
/// 检查文件头和当前配置是否一致(页大小 / order 改了就会对不上)。
|
||||
fn check_meta(page: &Page, page_size: usize, order: usize) -> io::Result<()> {
|
||||
if page.data[0..4] != *META_MAGIC {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"not a chusql index file (bad magic)",
|
||||
));
|
||||
}
|
||||
let stored_page = read_u32(page, OFF_META_PAGE_SIZE) as usize;
|
||||
let stored_order = read_u16(page, OFF_META_ORDER) as usize;
|
||||
if stored_page != page_size || stored_order != order {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!(
|
||||
"index file was built with page.size={} btree.order={}, config says page.size={} btree.order={}",
|
||||
stored_page, stored_order, page_size, order
|
||||
),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 一个节点在内存里的样子。
|
||||
enum NodeData {
|
||||
Leaf {
|
||||
keys: Vec<i64>,
|
||||
values: Vec<u64>,
|
||||
next: PageId,
|
||||
},
|
||||
Internal {
|
||||
keys: Vec<i64>,
|
||||
children: Vec<PageId>,
|
||||
},
|
||||
}
|
||||
|
||||
/// 从一页里读出节点。
|
||||
fn read_node(p: &Page, order: usize) -> NodeData {
|
||||
let ty = read_u8(p, OFF_TYPE);
|
||||
let n = read_u16(p, OFF_COUNT) as usize;
|
||||
let payload = off_payload(order);
|
||||
if ty == NODE_LEAF {
|
||||
let next = read_u64(p, OFF_NEXT);
|
||||
let mut keys = Vec::with_capacity(n);
|
||||
let mut values = Vec::with_capacity(n);
|
||||
for i in 0..n {
|
||||
keys.push(read_u64(p, OFF_KEYS + i * 8) as i64);
|
||||
values.push(read_u64(p, payload + i * 8));
|
||||
}
|
||||
NodeData::Leaf { keys, values, next }
|
||||
} else {
|
||||
let mut keys = Vec::with_capacity(n);
|
||||
let mut children = Vec::with_capacity(n + 1);
|
||||
for i in 0..n {
|
||||
keys.push(read_u64(p, OFF_KEYS + i * 8) as i64);
|
||||
}
|
||||
for i in 0..(n + 1) {
|
||||
children.push(read_u64(p, payload + i * 8));
|
||||
}
|
||||
NodeData::Internal { keys, children }
|
||||
}
|
||||
}
|
||||
|
||||
/// 把节点写回一页(先清零,避免残留)。
|
||||
fn write_node(p: &mut Page, node: &NodeData, order: usize) {
|
||||
p.zero();
|
||||
let payload = off_payload(order);
|
||||
match node {
|
||||
NodeData::Leaf { keys, values, next } => {
|
||||
write_u8(p, OFF_TYPE, NODE_LEAF);
|
||||
write_u16(p, OFF_COUNT, keys.len() as u16);
|
||||
write_u64(p, OFF_NEXT, *next);
|
||||
for (i, k) in keys.iter().enumerate() {
|
||||
write_u64(p, OFF_KEYS + i * 8, *k as u64);
|
||||
}
|
||||
for (i, v) in values.iter().enumerate() {
|
||||
write_u64(p, payload + i * 8, *v);
|
||||
}
|
||||
}
|
||||
NodeData::Internal { keys, children } => {
|
||||
write_u8(p, OFF_TYPE, NODE_INTERNAL);
|
||||
write_u16(p, OFF_COUNT, keys.len() as u16);
|
||||
for (i, k) in keys.iter().enumerate() {
|
||||
write_u64(p, OFF_KEYS + i * 8, *k as u64);
|
||||
}
|
||||
for (i, c) in children.iter().enumerate() {
|
||||
write_u64(p, payload + i * 8, *c);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 二分找 key 应该下探到的孩子下标。
|
||||
fn child_index(keys: &[i64], key: i64) -> usize {
|
||||
let mut lo = 0;
|
||||
let mut hi = keys.len();
|
||||
while lo < hi {
|
||||
let mid = (lo + hi) / 2;
|
||||
if keys[mid] <= key {
|
||||
lo = mid + 1;
|
||||
} else {
|
||||
hi = mid;
|
||||
}
|
||||
}
|
||||
lo
|
||||
}
|
||||
|
||||
/// 磁盘版 B+ 树。
|
||||
pub struct DiskBTree {
|
||||
file: PageFile,
|
||||
order: usize,
|
||||
}
|
||||
|
||||
impl DiskBTree {
|
||||
/// 打开或新建索引文件;页大小 / order 和文件头里记的不一致就报错。
|
||||
pub fn open<P: AsRef<Path>>(path: P, page_size: usize, order: usize) -> io::Result<Self> {
|
||||
if !fits_in_page(page_size, order) {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
format!(
|
||||
"btree order {} does not fit in a {}-byte page (max {})",
|
||||
order,
|
||||
page_size,
|
||||
max_order(page_size)
|
||||
),
|
||||
));
|
||||
}
|
||||
let mut file = PageFile::open(path, page_size)?;
|
||||
if file.num_pages()? == 0 {
|
||||
let mut meta = Page::new(META_PAGE, page_size);
|
||||
write_meta(&mut meta, page_size, order, 0);
|
||||
file.write_page(&meta)?;
|
||||
} else {
|
||||
let meta = file.read_page(META_PAGE)?;
|
||||
check_meta(&meta, page_size, order)?;
|
||||
}
|
||||
Ok(DiskBTree { file, order })
|
||||
}
|
||||
|
||||
/// 读根页号;0 表示空树。
|
||||
fn root(&mut self) -> io::Result<Option<PageId>> {
|
||||
let meta = self.file.read_page(META_PAGE)?;
|
||||
let r = read_u64(&meta, OFF_META_ROOT);
|
||||
Ok(if r == 0 { None } else { Some(r) })
|
||||
}
|
||||
|
||||
/// 写根页号。
|
||||
fn set_root(&mut self, r: Option<PageId>) -> io::Result<()> {
|
||||
let mut meta = self.file.read_page(META_PAGE)?;
|
||||
write_u64(&mut meta, OFF_META_ROOT, r.unwrap_or(0));
|
||||
self.file.write_page(&meta)
|
||||
}
|
||||
|
||||
/// 分配一个新页。
|
||||
fn alloc(&mut self) -> io::Result<PageId> {
|
||||
Ok(self.file.append_page()?.id)
|
||||
}
|
||||
|
||||
/// 清空整棵树:截断文件后重写文件头(根页号归零)。
|
||||
pub fn clear(&mut self) -> io::Result<()> {
|
||||
let page_size = self.file.page_size();
|
||||
self.file.truncate()?;
|
||||
let mut meta = Page::new(META_PAGE, page_size);
|
||||
write_meta(&mut meta, page_size, self.order, 0);
|
||||
self.file.write_page(&meta)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 空页,用来拼一个新节点。
|
||||
fn new_page(&self, id: PageId) -> Page {
|
||||
Page::new(id, self.file.page_size())
|
||||
}
|
||||
|
||||
/// 按 key 找 value。
|
||||
pub fn get(&mut self, key: i64) -> io::Result<Option<u64>> {
|
||||
match self.root()? {
|
||||
None => Ok(None),
|
||||
Some(r) => self.get_rec(r, key),
|
||||
}
|
||||
}
|
||||
|
||||
/// 递归查找。
|
||||
fn get_rec(&mut self, page_id: PageId, key: i64) -> io::Result<Option<u64>> {
|
||||
let p = self.file.read_page(page_id)?;
|
||||
match read_node(&p, self.order) {
|
||||
NodeData::Leaf { keys, values, .. } => {
|
||||
Ok(keys.binary_search(&key).ok().map(|i| values[i]))
|
||||
}
|
||||
NodeData::Internal { keys, children } => {
|
||||
let i = child_index(&keys, key);
|
||||
self.get_rec(children[i], key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 插入 / 覆盖一个键值对。
|
||||
pub fn insert(&mut self, key: i64, value: u64) -> io::Result<()> {
|
||||
match self.root()? {
|
||||
None => {
|
||||
let id = self.alloc()?;
|
||||
let node = NodeData::Leaf {
|
||||
keys: vec![key],
|
||||
values: vec![value],
|
||||
next: 0,
|
||||
};
|
||||
let mut p = self.new_page(id);
|
||||
write_node(&mut p, &node, self.order);
|
||||
self.file.write_page(&p)?;
|
||||
self.set_root(Some(id))
|
||||
}
|
||||
Some(r) => {
|
||||
let (_, split) = self.insert_rec(r, key, value)?;
|
||||
if let Some((sep, right_id)) = split {
|
||||
// 根分裂:长高一层。
|
||||
let root_id = self.alloc()?;
|
||||
let node = NodeData::Internal {
|
||||
keys: vec![sep],
|
||||
children: vec![r, right_id],
|
||||
};
|
||||
let mut p = self.new_page(root_id);
|
||||
write_node(&mut p, &node, self.order);
|
||||
self.file.write_page(&p)?;
|
||||
self.set_root(Some(root_id))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 递归插入;返回可能上提的 (分隔键, 新右页号)。
|
||||
fn insert_rec(
|
||||
&mut self,
|
||||
page_id: PageId,
|
||||
key: i64,
|
||||
value: u64,
|
||||
) -> io::Result<(PageId, Option<(i64, PageId)>)> {
|
||||
let p = self.file.read_page(page_id)?;
|
||||
let limit = max_keys(self.order);
|
||||
match read_node(&p, self.order) {
|
||||
NodeData::Leaf { mut keys, mut values, next } => {
|
||||
match keys.binary_search(&key) {
|
||||
Ok(i) => values[i] = value,
|
||||
Err(i) => {
|
||||
keys.insert(i, key);
|
||||
values.insert(i, value);
|
||||
}
|
||||
}
|
||||
if keys.len() <= limit {
|
||||
let mut p2 = self.new_page(page_id);
|
||||
write_node(&mut p2, &NodeData::Leaf { keys, values, next }, self.order);
|
||||
self.file.write_page(&p2)?;
|
||||
Ok((page_id, None))
|
||||
} else {
|
||||
// 叶子分裂:右叶最小键复制上提。
|
||||
let mid = keys.len() / 2;
|
||||
let rkeys = keys.split_off(mid);
|
||||
let rvalues = values.split_off(mid);
|
||||
let sep = rkeys[0];
|
||||
|
||||
let right_id = self.alloc()?;
|
||||
let mut rp = self.new_page(right_id);
|
||||
write_node(
|
||||
&mut rp,
|
||||
&NodeData::Leaf { keys: rkeys, values: rvalues, next },
|
||||
self.order,
|
||||
);
|
||||
self.file.write_page(&rp)?;
|
||||
|
||||
let mut lp = self.new_page(page_id);
|
||||
write_node(
|
||||
&mut lp,
|
||||
&NodeData::Leaf { keys, values, next: right_id },
|
||||
self.order,
|
||||
);
|
||||
self.file.write_page(&lp)?;
|
||||
|
||||
Ok((page_id, Some((sep, right_id))))
|
||||
}
|
||||
}
|
||||
|
||||
NodeData::Internal { mut keys, mut children } => {
|
||||
let i = child_index(&keys, key);
|
||||
let (new_child, split) = self.insert_rec(children[i], key, value)?;
|
||||
children[i] = new_child;
|
||||
match split {
|
||||
None => {
|
||||
let mut p2 = self.new_page(page_id);
|
||||
write_node(&mut p2, &NodeData::Internal { keys, children }, self.order);
|
||||
self.file.write_page(&p2)?;
|
||||
Ok((page_id, None))
|
||||
}
|
||||
Some((sep, right_id)) => {
|
||||
keys.insert(i, sep);
|
||||
children.insert(i + 1, right_id);
|
||||
if keys.len() <= limit {
|
||||
let mut p2 = self.new_page(page_id);
|
||||
write_node(
|
||||
&mut p2,
|
||||
&NodeData::Internal { keys, children },
|
||||
self.order,
|
||||
);
|
||||
self.file.write_page(&p2)?;
|
||||
Ok((page_id, None))
|
||||
} else {
|
||||
// 内部节点分裂:中间键上提,不再保留原件。
|
||||
let mid = keys.len() / 2;
|
||||
let up = keys[mid];
|
||||
let rkeys = keys.split_off(mid + 1);
|
||||
keys.truncate(mid);
|
||||
let rchildren = children.split_off(mid + 1);
|
||||
|
||||
let right_id = self.alloc()?;
|
||||
let mut rp = self.new_page(right_id);
|
||||
write_node(
|
||||
&mut rp,
|
||||
&NodeData::Internal { keys: rkeys, children: rchildren },
|
||||
self.order,
|
||||
);
|
||||
self.file.write_page(&rp)?;
|
||||
|
||||
let mut lp = self.new_page(page_id);
|
||||
write_node(
|
||||
&mut lp,
|
||||
&NodeData::Internal { keys, children },
|
||||
self.order,
|
||||
);
|
||||
self.file.write_page(&lp)?;
|
||||
|
||||
Ok((page_id, Some((up, right_id))))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 按 key 升序返回所有键值对。
|
||||
pub fn iter_all(&mut self) -> io::Result<Vec<(i64, u64)>> {
|
||||
let mut out = Vec::new();
|
||||
if let Some(r) = self.root()? {
|
||||
self.walk(r, &mut out)?;
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// 中序遍历一棵子树。
|
||||
fn walk(&mut self, page_id: PageId, out: &mut Vec<(i64, u64)>) -> io::Result<()> {
|
||||
let p = self.file.read_page(page_id)?;
|
||||
match read_node(&p, self.order) {
|
||||
NodeData::Leaf { keys, values, .. } => {
|
||||
for i in 0..keys.len() {
|
||||
out.push((keys[i], values[i]));
|
||||
}
|
||||
}
|
||||
NodeData::Internal { children, .. } => {
|
||||
for c in children {
|
||||
self.walk(c, out)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
//! 数据字典:记录每张表有哪些列、什么类型。
|
||||
//! 存在 data/catalog.json。insert 时自动补列,describe 时读回。
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::protocol::{ColumnType, Row, SchemaColumn};
|
||||
|
||||
/// 一张表的 schema。
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct TableSchema {
|
||||
pub columns: Vec<SchemaColumn>,
|
||||
}
|
||||
|
||||
/// 整个数据字典。
|
||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||
pub struct Catalog {
|
||||
tables: BTreeMap<String, TableSchema>,
|
||||
}
|
||||
|
||||
impl Catalog {
|
||||
/// 从文件读;文件不存在返回空字典。
|
||||
pub fn load<P: AsRef<Path>>(path: P) -> io::Result<Self> {
|
||||
if !path.as_ref().exists() {
|
||||
return Ok(Catalog::default());
|
||||
}
|
||||
let bytes = std::fs::read(path)?;
|
||||
if bytes.is_empty() {
|
||||
return Ok(Catalog::default());
|
||||
}
|
||||
serde_json::from_slice(&bytes).map_err(io::Error::other)
|
||||
}
|
||||
|
||||
/// 写回文件。
|
||||
pub fn save<P: AsRef<Path>>(&self, path: P) -> io::Result<()> {
|
||||
let bytes = serde_json::to_vec_pretty(self).map_err(io::Error::other)?;
|
||||
std::fs::write(path, bytes)
|
||||
}
|
||||
|
||||
/// 一张表的 schema;没有就返回 None。
|
||||
pub fn describe(&self, table: &str) -> Option<&TableSchema> {
|
||||
self.tables.get(table)
|
||||
}
|
||||
|
||||
/// catalog 里记录的所有表名。
|
||||
pub fn table_names(&self) -> Vec<String> {
|
||||
self.tables.keys().cloned().collect()
|
||||
}
|
||||
|
||||
/// 按一行数据补列;已有的列不动。
|
||||
pub fn ensure_columns(&mut self, table: &str, row: &Row) {
|
||||
let entry = self.tables.entry(table.to_string()).or_default();
|
||||
for (k, v) in row {
|
||||
if entry.columns.iter().any(|c| &c.name == k) {
|
||||
continue;
|
||||
}
|
||||
if let Some(ty) = infer_type(v) {
|
||||
entry.columns.push(SchemaColumn {
|
||||
name: k.clone(),
|
||||
ty,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 按多行数据补列;replace_all 用。
|
||||
pub fn ensure_columns_from_rows(&mut self, table: &str, rows: &[Row]) {
|
||||
for r in rows {
|
||||
self.ensure_columns(table, r);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 JSON 值推断列类型;不支持的类型返回 None。
|
||||
fn infer_type(v: &serde_json::Value) -> Option<ColumnType> {
|
||||
match v {
|
||||
serde_json::Value::Number(_) => Some(ColumnType::Int),
|
||||
serde_json::Value::String(_) => Some(ColumnType::Str),
|
||||
serde_json::Value::Bool(_) => Some(ColumnType::Bool),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::log::Level;
|
||||
|
||||
// 配置:优先环境变量,其次 TOML 文件,最后内置默认值(下面写的就是默认值)。
|
||||
// 每个选项的来源都记在 Loaded::origins 里,server 启动时打进日志——读了哪个文件、
|
||||
// 哪些选项走了默认回退,一眼能看出来。
|
||||
|
||||
/// 默认配置文件名(相对当前工作目录);可用 CHUSQL_CONFIG 指到别处。
|
||||
pub const DEFAULT_CONFIG_PATH: &str = "chusql-storage.toml";
|
||||
|
||||
/// 默认页大小(字节)。
|
||||
pub const DEFAULT_PAGE_SIZE: usize = 4096;
|
||||
/// 默认 B+ 树节点最大孩子数。
|
||||
pub const DEFAULT_BTREE_ORDER: usize = 4;
|
||||
/// 默认数据目录。
|
||||
pub const DEFAULT_DATA_DIR: &str = "data";
|
||||
/// 默认管道名。
|
||||
pub const DEFAULT_PIPE_NAME: &str = "chusql-storage";
|
||||
/// 默认日志等级。
|
||||
pub const DEFAULT_LOG_LEVEL: &str = "info";
|
||||
|
||||
/// 页大小允许的范围。
|
||||
const MIN_PAGE_SIZE: usize = 512;
|
||||
const MAX_PAGE_SIZE: usize = 65536;
|
||||
/// B+ 树节点至少要放得下 2 个键,分裂规则才成立。
|
||||
const MIN_BTREE_ORDER: usize = 3;
|
||||
|
||||
/// 生效的配置。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Config {
|
||||
pub page_size: usize,
|
||||
pub btree_order: usize,
|
||||
pub data_dir: PathBuf,
|
||||
pub pipe_name: String,
|
||||
pub log_level: Level,
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Config {
|
||||
page_size: DEFAULT_PAGE_SIZE,
|
||||
btree_order: DEFAULT_BTREE_ORDER,
|
||||
data_dir: PathBuf::from(DEFAULT_DATA_DIR),
|
||||
pipe_name: DEFAULT_PIPE_NAME.to_string(),
|
||||
log_level: Level::Info,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 一个选项的来源。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Origin {
|
||||
Default,
|
||||
File(PathBuf),
|
||||
Env(&'static str),
|
||||
}
|
||||
|
||||
impl Origin {
|
||||
/// 日志里那一列怎么写。
|
||||
pub fn describe(&self) -> String {
|
||||
match self {
|
||||
Origin::Default => "default".to_string(),
|
||||
Origin::File(p) => format!("file {}", p.display()),
|
||||
Origin::Env(k) => format!("env {}", k),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 生效配置 + 每个选项的来源。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Loaded {
|
||||
pub config: Config,
|
||||
pub config_path: Option<PathBuf>,
|
||||
pub origins: Vec<(&'static str, String, Origin)>,
|
||||
}
|
||||
|
||||
/// TOML 文件里的形状:字段全部可选,没写的就当"文件没提这一项"。
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct FileConfig {
|
||||
page: Option<FilePage>,
|
||||
btree: Option<FileBtree>,
|
||||
storage: Option<FileStorage>,
|
||||
server: Option<FileServer>,
|
||||
log: Option<FileLog>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct FilePage {
|
||||
size: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct FileBtree {
|
||||
order: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct FileStorage {
|
||||
data_dir: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct FileServer {
|
||||
pipe_name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct FileLog {
|
||||
level: Option<String>,
|
||||
}
|
||||
|
||||
/// 从真实环境加载:CHUSQL_CONFIG(或当前目录的默认文件)+ 环境变量覆盖。
|
||||
pub fn load() -> Result<Loaded, String> {
|
||||
let path = match std::env::var("CHUSQL_CONFIG") {
|
||||
Ok(p) if !p.trim().is_empty() => Some(PathBuf::from(p)),
|
||||
_ => {
|
||||
let p = PathBuf::from(DEFAULT_CONFIG_PATH);
|
||||
if p.is_file() { Some(p) } else { None }
|
||||
}
|
||||
};
|
||||
let text = match &path {
|
||||
Some(p) => Some(
|
||||
std::fs::read_to_string(p)
|
||||
.map_err(|e| format!("cannot read config file {}: {}", p.display(), e))?,
|
||||
),
|
||||
None => None,
|
||||
};
|
||||
resolve(text.as_deref(), path, &|k| std::env::var(k).ok())
|
||||
}
|
||||
|
||||
/// 解析 TOML 文本 + 套环境变量覆盖;测试直接调这个,不碰真实环境。
|
||||
pub fn resolve(
|
||||
text: Option<&str>,
|
||||
path: Option<PathBuf>,
|
||||
env: &dyn Fn(&str) -> Option<String>,
|
||||
) -> Result<Loaded, String> {
|
||||
let file: FileConfig = match text {
|
||||
None => FileConfig::default(),
|
||||
Some(t) => toml::from_str(t).map_err(|e| match &path {
|
||||
Some(p) => format!("bad config file {}: {}", p.display(), e),
|
||||
None => format!("bad config: {}", e),
|
||||
})?,
|
||||
};
|
||||
|
||||
let file_origin = Origin::File(
|
||||
path.clone()
|
||||
.unwrap_or_else(|| PathBuf::from(DEFAULT_CONFIG_PATH)),
|
||||
);
|
||||
|
||||
let (page_size, page_origin) = pick(
|
||||
"CHUSQL_PAGE_SIZE",
|
||||
file.page.and_then(|p| p.size),
|
||||
DEFAULT_PAGE_SIZE,
|
||||
&file_origin,
|
||||
env,
|
||||
|s| {
|
||||
s.trim()
|
||||
.parse::<usize>()
|
||||
.map_err(|_| format!("not a number: {}", s))
|
||||
},
|
||||
)?;
|
||||
let (btree_order, order_origin) = pick(
|
||||
"CHUSQL_BTREE_ORDER",
|
||||
file.btree.and_then(|b| b.order),
|
||||
DEFAULT_BTREE_ORDER,
|
||||
&file_origin,
|
||||
env,
|
||||
|s| {
|
||||
s.trim()
|
||||
.parse::<usize>()
|
||||
.map_err(|_| format!("not a number: {}", s))
|
||||
},
|
||||
)?;
|
||||
let (data_dir, dir_origin) = pick(
|
||||
"CHUSQL_DATA_DIR",
|
||||
file.storage.and_then(|s| s.data_dir),
|
||||
DEFAULT_DATA_DIR.to_string(),
|
||||
&file_origin,
|
||||
env,
|
||||
|s| Ok(s.trim().to_string()),
|
||||
)?;
|
||||
let (pipe_name, pipe_origin) = pick(
|
||||
"CHUSQL_PIPE",
|
||||
file.server.and_then(|s| s.pipe_name),
|
||||
DEFAULT_PIPE_NAME.to_string(),
|
||||
&file_origin,
|
||||
env,
|
||||
|s| Ok(s.trim().to_string()),
|
||||
)?;
|
||||
let (log_level, log_origin) = pick(
|
||||
"CHUSQL_LOG",
|
||||
file.log.and_then(|l| l.level),
|
||||
DEFAULT_LOG_LEVEL.to_string(),
|
||||
&file_origin,
|
||||
env,
|
||||
|s| Ok(s.trim().to_string()),
|
||||
)?;
|
||||
let log_level = Level::parse(&log_level)
|
||||
.ok_or_else(|| format!("log.level is not one of off/error/warn/info/debug: {}", log_level))?;
|
||||
|
||||
if data_dir.is_empty() {
|
||||
return Err("storage.data_dir must not be empty".to_string());
|
||||
}
|
||||
if pipe_name.is_empty() {
|
||||
return Err("server.pipe_name must not be empty".to_string());
|
||||
}
|
||||
validate_layout(page_size, btree_order)?;
|
||||
|
||||
let origins = vec![
|
||||
("page.size", page_size.to_string(), page_origin),
|
||||
("btree.order", btree_order.to_string(), order_origin),
|
||||
("storage.data_dir", data_dir.clone(), dir_origin),
|
||||
("server.pipe_name", pipe_name.clone(), pipe_origin),
|
||||
("log.level", log_level.name().to_string(), log_origin),
|
||||
];
|
||||
|
||||
Ok(Loaded {
|
||||
config: Config {
|
||||
page_size,
|
||||
btree_order,
|
||||
data_dir: PathBuf::from(data_dir),
|
||||
pipe_name,
|
||||
log_level,
|
||||
},
|
||||
config_path: if text.is_some() { path } else { None },
|
||||
origins,
|
||||
})
|
||||
}
|
||||
|
||||
/// 取一个选项:环境变量 → 配置文件 → 默认值,并记下它从哪来。
|
||||
fn pick<T: Clone>(
|
||||
env_key: &'static str,
|
||||
file_value: Option<T>,
|
||||
default: T,
|
||||
file_origin: &Origin,
|
||||
env: &dyn Fn(&str) -> Option<String>,
|
||||
parse: impl Fn(&str) -> Result<T, String>,
|
||||
) -> Result<(T, Origin), String> {
|
||||
if let Some(raw) = env(env_key) {
|
||||
let value = parse(&raw).map_err(|e| format!("{}: {}", env_key, e))?;
|
||||
return Ok((value, Origin::Env(env_key)));
|
||||
}
|
||||
if let Some(value) = file_value {
|
||||
return Ok((value, file_origin.clone()));
|
||||
}
|
||||
Ok((default, Origin::Default))
|
||||
}
|
||||
|
||||
/// 检查选项本身和选项之间是否说得通。
|
||||
fn validate_layout(page_size: usize, btree_order: usize) -> Result<(), String> {
|
||||
if !(MIN_PAGE_SIZE..=MAX_PAGE_SIZE).contains(&page_size) || !page_size.is_power_of_two() {
|
||||
return Err(format!(
|
||||
"page.size must be a power of two between {} and {}, got {}",
|
||||
MIN_PAGE_SIZE, MAX_PAGE_SIZE, page_size
|
||||
));
|
||||
}
|
||||
if btree_order < MIN_BTREE_ORDER {
|
||||
return Err(format!(
|
||||
"btree.order must be at least {}, got {}",
|
||||
MIN_BTREE_ORDER, btree_order
|
||||
));
|
||||
}
|
||||
if !crate::btree::fits_in_page(page_size, btree_order) {
|
||||
return Err(format!(
|
||||
"btree.order {} does not fit in a {}-byte page (max {})",
|
||||
btree_order,
|
||||
page_size,
|
||||
crate::btree::max_order(page_size)
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::btree::DiskBTree;
|
||||
use crate::page::PageId;
|
||||
use crate::page::{Page, PageFile};
|
||||
use crate::protocol::Row;
|
||||
|
||||
// 堆表:把行按插入顺序塞进页里,支持全表扫描。页大小由配置给(默认 4096)。
|
||||
// 页内布局(slotted page):
|
||||
// [0..2] slot_count: u16(小端)
|
||||
// [2..] 槽位目录,每槽 8 字节:offset u32 + len u32
|
||||
// [中间] 空闲空间
|
||||
// [页尾] 行数据,从后往前长
|
||||
// 删除:把槽位的 len 置 0,数据不动(vacuum 以后再说)。
|
||||
|
||||
/// 页头:slot_count 占 2 字节。
|
||||
const HEADER_SIZE: usize = 2;
|
||||
/// 一个槽位占 8 字节:offset(u32) + len(u32)。
|
||||
const SLOT_SIZE: usize = 8;
|
||||
/// 槽位目录起点。
|
||||
const SLOT_DIR_START: usize = HEADER_SIZE;
|
||||
|
||||
/// 从字节里读一个小端 u16。
|
||||
fn read_u16(buf: &[u8], off: usize) -> u16 {
|
||||
u16::from_le_bytes([buf[off], buf[off + 1]])
|
||||
}
|
||||
|
||||
/// 往字节里写一个小端 u16。
|
||||
fn write_u16(buf: &mut [u8], off: usize, v: u16) {
|
||||
buf[off..off + 2].copy_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
|
||||
/// 从字节里读一个小端 u32。
|
||||
fn read_u32(buf: &[u8], off: usize) -> u32 {
|
||||
u32::from_le_bytes([buf[off], buf[off + 1], buf[off + 2], buf[off + 3]])
|
||||
}
|
||||
|
||||
/// 往字节里写一个小端 u32。
|
||||
fn write_u32(buf: &mut [u8], off: usize, v: u32) {
|
||||
buf[off..off + 4].copy_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
|
||||
/// 一个 (页号, 槽位号) 打包成一个 u64。页号高 48 位,槽位号低 16 位。
|
||||
fn pack_position(page_id: PageId, slot: u16) -> u64 {
|
||||
(page_id << 16) | (slot as u64)
|
||||
}
|
||||
|
||||
/// 从 u64 解回 (页号, 槽位号)。
|
||||
fn unpack_position(v: u64) -> (PageId, u16) {
|
||||
(v >> 16, (v & 0xFFFF) as u16)
|
||||
}
|
||||
|
||||
/// 页内操作:不管文件,只管一页里的字节。
|
||||
pub struct HeapPage;
|
||||
|
||||
impl HeapPage {
|
||||
/// 这一页里有多少个槽位。
|
||||
pub fn slot_count(page: &Page) -> u16 {
|
||||
read_u16(&page.data, 0)
|
||||
}
|
||||
|
||||
/// 改写槽位数量。
|
||||
fn set_slot_count(page: &mut Page, v: u16) {
|
||||
write_u16(&mut page.data, 0, v);
|
||||
}
|
||||
|
||||
/// 空闲空间下界:槽位目录的末尾。
|
||||
fn free_start(page: &Page) -> usize {
|
||||
SLOT_DIR_START + (Self::slot_count(page) as usize) * SLOT_SIZE
|
||||
}
|
||||
|
||||
/// 空闲空间上界:所有存活行里最靠前的那个字节偏移(没存活行时就是页尾)。
|
||||
fn free_end(page: &Page) -> usize {
|
||||
let n = Self::slot_count(page);
|
||||
let mut min_off = page.data.len() as u32;
|
||||
for i in 0..n as usize {
|
||||
let slot_off = SLOT_DIR_START + i * SLOT_SIZE;
|
||||
let off = read_u32(&page.data, slot_off);
|
||||
let len = read_u32(&page.data, slot_off + 4);
|
||||
if len > 0 && off < min_off {
|
||||
min_off = off;
|
||||
}
|
||||
}
|
||||
min_off as usize
|
||||
}
|
||||
|
||||
/// 试插一行:成功返回槽位号,放不下返回 None。
|
||||
pub fn insert_tuple(page: &mut Page, tuple: &[u8]) -> Option<u16> {
|
||||
let n = Self::slot_count(page);
|
||||
let free_start = Self::free_start(page);
|
||||
let free_end = Self::free_end(page);
|
||||
|
||||
if free_start + SLOT_SIZE + tuple.len() > free_end {
|
||||
return None;
|
||||
}
|
||||
|
||||
// 行数据紧贴已有数据前面
|
||||
let tuple_off = free_end - tuple.len();
|
||||
page.data[tuple_off..tuple_off + tuple.len()].copy_from_slice(tuple);
|
||||
|
||||
// 追加槽位
|
||||
let slot_off = SLOT_DIR_START + (n as usize) * SLOT_SIZE;
|
||||
write_u32(&mut page.data, slot_off, tuple_off as u32);
|
||||
write_u32(&mut page.data, slot_off + 4, tuple.len() as u32);
|
||||
Self::set_slot_count(page, n + 1);
|
||||
|
||||
Some(n)
|
||||
}
|
||||
|
||||
/// 读槽位里的行字节;槽位不存在或已删除返回 None。
|
||||
pub fn get_tuple(page: &Page, slot: u16) -> Option<Vec<u8>> {
|
||||
let n = Self::slot_count(page);
|
||||
if slot >= n {
|
||||
return None;
|
||||
}
|
||||
let slot_off = SLOT_DIR_START + (slot as usize) * SLOT_SIZE;
|
||||
let off = read_u32(&page.data, slot_off) as usize;
|
||||
let len = read_u32(&page.data, slot_off + 4) as usize;
|
||||
if len == 0 {
|
||||
return None;
|
||||
}
|
||||
Some(page.data[off..off + len].to_vec())
|
||||
}
|
||||
|
||||
/// 所有槽位号(含已删除的)。
|
||||
pub fn iter_slots(page: &Page) -> Vec<u16> {
|
||||
(0..Self::slot_count(page)).collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// 堆表:一个文件 = 若干页,顺序追加行。
|
||||
pub struct HeapTable {
|
||||
file: PageFile,
|
||||
index: Option<DiskBTree>,
|
||||
}
|
||||
|
||||
impl HeapTable {
|
||||
/// 打开(或创建)一张堆表。
|
||||
pub fn open<P: AsRef<Path>>(path: P, page_size: usize) -> io::Result<Self> {
|
||||
Ok(HeapTable {
|
||||
file: PageFile::open(path, page_size)?,
|
||||
index: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// 打开带 B+ 树索引的表;索引文件不存在会自动建。
|
||||
pub fn open_indexed<P: AsRef<Path>, Q: AsRef<Path>>(
|
||||
path: P,
|
||||
index_path: Q,
|
||||
page_size: usize,
|
||||
btree_order: usize,
|
||||
) -> io::Result<Self> {
|
||||
Ok(HeapTable {
|
||||
file: PageFile::open(path, page_size)?,
|
||||
index: Some(DiskBTree::open(index_path, page_size, btree_order)?),
|
||||
})
|
||||
}
|
||||
|
||||
/// 插入一行;不更新索引。
|
||||
pub fn insert(&mut self, row: &Row) -> io::Result<()> {
|
||||
self.insert_returning_position(row).map(|_| ())
|
||||
}
|
||||
|
||||
/// 插入一行并返回它的 (页号, 槽位号)。
|
||||
pub fn insert_returning_position(&mut self, row: &Row) -> io::Result<(PageId, u16)> {
|
||||
let tuple = serde_json::to_vec(row).map_err(io::Error::other)?;
|
||||
|
||||
if HEADER_SIZE + SLOT_SIZE + tuple.len() > self.file.page_size() {
|
||||
return Err(io::Error::other(format!(
|
||||
"tuple too large: {} bytes",
|
||||
tuple.len()
|
||||
)));
|
||||
}
|
||||
|
||||
let n = self.file.num_pages()?;
|
||||
if n > 0 {
|
||||
let last_id = n - 1;
|
||||
let mut p = self.file.read_page(last_id)?;
|
||||
if let Some(slot) = HeapPage::insert_tuple(&mut p, &tuple) {
|
||||
self.file.write_page(&p)?;
|
||||
return Ok((last_id, slot));
|
||||
}
|
||||
}
|
||||
|
||||
let mut p = self.file.append_page()?;
|
||||
let page_id = p.id;
|
||||
let slot = HeapPage::insert_tuple(&mut p, &tuple).expect("pre-checked fits");
|
||||
self.file.write_page(&p)?;
|
||||
Ok((page_id, slot))
|
||||
}
|
||||
|
||||
/// 插入一行,并把 (key, 位置) 写进索引。
|
||||
pub fn insert_keyed(&mut self, row: &Row, key: i64) -> io::Result<()> {
|
||||
let (page_id, slot) = self.insert_returning_position(row)?;
|
||||
if let Some(idx) = &mut self.index {
|
||||
idx.insert(key, pack_position(page_id, slot))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 按索引键取一行;没索引或键不存在返回 None。
|
||||
pub fn get_by_key(&mut self, key: i64) -> io::Result<Option<Row>> {
|
||||
let pos = match &mut self.index {
|
||||
None => return Ok(None),
|
||||
Some(idx) => match idx.get(key)? {
|
||||
None => return Ok(None),
|
||||
Some(p) => p,
|
||||
},
|
||||
};
|
||||
let (page_id, slot) = unpack_position(pos);
|
||||
self.read_at(page_id, slot)
|
||||
}
|
||||
|
||||
/// 按 (页号, 槽位号) 读一行。
|
||||
pub fn read_at(&mut self, page_id: PageId, slot: u16) -> io::Result<Option<Row>> {
|
||||
let p = self.file.read_page(page_id)?;
|
||||
match HeapPage::get_tuple(&p, slot) {
|
||||
None => Ok(None),
|
||||
Some(bytes) => {
|
||||
let row: Row = serde_json::from_slice(&bytes).map_err(io::Error::other)?;
|
||||
Ok(Some(row))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 全表扫描:按页顺序、按槽位顺序返回所有行。
|
||||
pub fn scan(&mut self) -> io::Result<Vec<Row>> {
|
||||
let n = self.file.num_pages()?;
|
||||
let mut rows = Vec::new();
|
||||
for i in 0..n {
|
||||
let p = self.file.read_page(i)?;
|
||||
for slot in HeapPage::iter_slots(&p) {
|
||||
if let Some(bytes) = HeapPage::get_tuple(&p, slot) {
|
||||
let row: Row = serde_json::from_slice(&bytes)
|
||||
.map_err(io::Error::other)?;
|
||||
rows.push(row);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// 整表改写:清空堆表和索引,再把新行逐行插回去。
|
||||
/// 行里有整数 id 就顺手重建索引 —— 堆表位置变了,旧索引必须作废,否则会按老位置读出别的行。
|
||||
pub fn replace_all(&mut self, rows: &[Row]) -> io::Result<()> {
|
||||
self.file.truncate()?;
|
||||
if let Some(idx) = &mut self.index {
|
||||
idx.clear()?;
|
||||
}
|
||||
for r in rows {
|
||||
let (page_id, slot) = self.insert_returning_position(r)?;
|
||||
if let (Some(idx), Some(key)) = (&mut self.index, row_key(r)) {
|
||||
idx.insert(key, pack_position(page_id, slot))?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// 一行里的索引键:id 列是整数就用它(和 Haskell 侧"主键列叫 id"的约定一致)。
|
||||
fn row_key(row: &Row) -> Option<i64> {
|
||||
match row.get("id") {
|
||||
Some(serde_json::Value::Number(n)) => n.as_i64(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// 存储层库入口:对外暴露 page / protocol / heap / btree / config / log(server 与集成测试都从这里引)。
|
||||
|
||||
pub mod btree;
|
||||
pub mod config;
|
||||
pub mod heap;
|
||||
pub mod log;
|
||||
pub mod page;
|
||||
pub mod protocol;
|
||||
pub mod catalog;
|
||||
pub mod wal;
|
||||
@@ -0,0 +1,387 @@
|
||||
use std::fmt;
|
||||
use std::io::{IsTerminal, Write};
|
||||
use std::sync::atomic::{AtomicU8, Ordering};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
// 极简日志:等级存在一个原子变量里,被关掉的等级只花一次原子读——不求值参数、不做格式化。
|
||||
// 行格式:[等级 本地日期时间] [来源] 消息
|
||||
// - ERROR 加粗红、WARN 加粗橙、DEBUG 加粗不变色(只在 stderr 是终端时着色,重定向到文件时是纯文本)
|
||||
// - 来源区分 core(服务自身:启动、配置)和 pipe(由管道请求触发)
|
||||
// 等级由配置文件的 log.level 或环境变量 CHUSQL_LOG 决定。
|
||||
|
||||
/// 转义序列收尾。
|
||||
const RESET: &str = "\u{1b}[0m";
|
||||
/// 加粗红(ERROR)。
|
||||
const BOLD_RED: &str = "\u{1b}[1m\u{1b}[31m";
|
||||
/// 加粗橙(WARN):256 色里的 208 号。
|
||||
const BOLD_ORANGE: &str = "\u{1b}[1m\u{1b}[38;5;208m";
|
||||
/// 只加粗(DEBUG)。
|
||||
const BOLD: &str = "\u{1b}[1m";
|
||||
|
||||
/// 日志等级;Off 表示全关。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum Level {
|
||||
Off,
|
||||
Error,
|
||||
Warn,
|
||||
Info,
|
||||
Debug,
|
||||
}
|
||||
|
||||
/// 日志来源:core = 服务自身,pipe = 由管道请求触发。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Channel {
|
||||
Core,
|
||||
Pipe,
|
||||
}
|
||||
|
||||
impl Channel {
|
||||
/// 日志行里的来源标签。
|
||||
fn tag(self) -> &'static str {
|
||||
match self {
|
||||
Channel::Core => "core",
|
||||
Channel::Pipe => "pipe",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Level {
|
||||
/// 解析等级名(大小写不敏感)。
|
||||
pub fn parse(s: &str) -> Option<Level> {
|
||||
match s.trim().to_ascii_lowercase().as_str() {
|
||||
"off" => Some(Level::Off),
|
||||
"error" => Some(Level::Error),
|
||||
"warn" | "warning" => Some(Level::Warn),
|
||||
"info" => Some(Level::Info),
|
||||
"debug" => Some(Level::Debug),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 等级名(写日志和写配置说明用)。
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Level::Off => "off",
|
||||
Level::Error => "error",
|
||||
Level::Warn => "warn",
|
||||
Level::Info => "info",
|
||||
Level::Debug => "debug",
|
||||
}
|
||||
}
|
||||
|
||||
/// 日志行里的标签,固定 5 字符宽,方便对齐。
|
||||
fn tag(self) -> &'static str {
|
||||
match self {
|
||||
Level::Error => "ERROR",
|
||||
Level::Warn => "WARN ",
|
||||
Level::Info => "INFO ",
|
||||
_ => "DEBUG",
|
||||
}
|
||||
}
|
||||
|
||||
/// 这个等级要不要着色(INFO 不着色)。
|
||||
fn color(self) -> &'static str {
|
||||
match self {
|
||||
Level::Error => BOLD_RED,
|
||||
Level::Warn => BOLD_ORANGE,
|
||||
Level::Debug => BOLD,
|
||||
_ => "",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 当前等级,默认 info。
|
||||
static LEVEL: AtomicU8 = AtomicU8::new(Level::Info as u8);
|
||||
|
||||
/// stderr 是不是终端(决定要不要着色),只问一次。
|
||||
static COLOR: OnceLock<bool> = OnceLock::new();
|
||||
|
||||
/// 设置全局等级。
|
||||
pub fn set_level(level: Level) {
|
||||
LEVEL.store(level as u8, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// 这个等级要不要输出——热路径上只走这一步(一次原子读 + 比较)。
|
||||
#[inline]
|
||||
pub fn enabled(level: Level) -> bool {
|
||||
level as u8 <= LEVEL.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// 启动时调一次:Windows 上给 stderr 打开 VT 处理,让 ANSI 颜色能被认识。
|
||||
pub fn init() {
|
||||
enable_vt();
|
||||
}
|
||||
|
||||
/// 写出一行;只在 enabled 为真时调用。
|
||||
pub fn write(channel: Channel, level: Level, args: fmt::Arguments) {
|
||||
let msg = args.to_string();
|
||||
let line = render(level, channel, color_enabled(), &now(), &msg);
|
||||
let stderr = std::io::stderr();
|
||||
let mut out = stderr.lock();
|
||||
let _ = writeln!(out, "{}", line);
|
||||
}
|
||||
|
||||
/// stderr 是终端才着色;CHUSQL_LOG_COLOR=always / never 可以强制开关。
|
||||
fn color_enabled() -> bool {
|
||||
*COLOR.get_or_init(|| match std::env::var("CHUSQL_LOG_COLOR").as_deref().map(str::trim) {
|
||||
Ok("always") | Ok("yes") | Ok("1") => true,
|
||||
Ok("never") | Ok("no") | Ok("0") => false,
|
||||
_ => std::io::stderr().is_terminal(),
|
||||
})
|
||||
}
|
||||
|
||||
/// 拼一行:[等级 时间] [来源] 消息。纯函数,方便测试。
|
||||
fn render(level: Level, channel: Channel, color: bool, stamp: &Stamp, msg: &str) -> String {
|
||||
let tag = level.tag();
|
||||
let paint = level.color();
|
||||
let level_text = if color && !paint.is_empty() {
|
||||
format!("{}{}{}", paint, tag, RESET)
|
||||
} else {
|
||||
tag.to_string()
|
||||
};
|
||||
format!("[{} {}] [{}] {}", level_text, stamp, channel.tag(), msg)
|
||||
}
|
||||
|
||||
/// 一个本地日期时间。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Stamp {
|
||||
pub year: u16,
|
||||
pub month: u16,
|
||||
pub day: u16,
|
||||
pub hour: u16,
|
||||
pub minute: u16,
|
||||
pub second: u16,
|
||||
pub milli: u16,
|
||||
}
|
||||
|
||||
impl fmt::Display for Stamp {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"{:04}-{:02}-{:02} {:02}:{:02}:{:02}.{:03}",
|
||||
self.year, self.month, self.day, self.hour, self.minute, self.second, self.milli
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 当前本地时间:Windows 上走 kernel32::GetLocalTime。
|
||||
#[cfg(windows)]
|
||||
fn now() -> Stamp {
|
||||
#[repr(C)]
|
||||
struct SystemTime {
|
||||
year: u16,
|
||||
month: u16,
|
||||
day_of_week: u16,
|
||||
day: u16,
|
||||
hour: u16,
|
||||
minute: u16,
|
||||
second: u16,
|
||||
milliseconds: u16,
|
||||
}
|
||||
|
||||
#[link(name = "kernel32")]
|
||||
unsafe extern "system" {
|
||||
fn GetLocalTime(out: *mut SystemTime);
|
||||
}
|
||||
|
||||
let mut t = SystemTime {
|
||||
year: 0,
|
||||
month: 0,
|
||||
day_of_week: 0,
|
||||
day: 0,
|
||||
hour: 0,
|
||||
minute: 0,
|
||||
second: 0,
|
||||
milliseconds: 0,
|
||||
};
|
||||
unsafe { GetLocalTime(&mut t) };
|
||||
Stamp {
|
||||
year: t.year,
|
||||
month: t.month,
|
||||
day: t.day,
|
||||
hour: t.hour,
|
||||
minute: t.minute,
|
||||
second: t.second,
|
||||
milli: t.milliseconds,
|
||||
}
|
||||
}
|
||||
|
||||
/// 当前时间:其他平台没有取本地时区的零依赖办法,退回 UTC。
|
||||
#[cfg(not(windows))]
|
||||
fn now() -> Stamp {
|
||||
let d = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default();
|
||||
let secs = d.as_secs();
|
||||
let (year, month, day) = civil_from_days((secs / 86_400) as i64);
|
||||
Stamp {
|
||||
year: year as u16,
|
||||
month: month as u16,
|
||||
day: day as u16,
|
||||
hour: ((secs % 86_400) / 3600) as u16,
|
||||
minute: ((secs % 3_600) / 60) as u16,
|
||||
second: (secs % 60) as u16,
|
||||
milli: d.subsec_millis() as u16,
|
||||
}
|
||||
}
|
||||
|
||||
/// 把"距 1970-01-01 的天数"换算成 (年, 月, 日):非 Windows 平台算时间用,单元测试也走这里。
|
||||
pub fn civil_from_days(days: i64) -> (i64, u16, u16) {
|
||||
let z = days + 719_468;
|
||||
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
|
||||
let doe = z - era * 146_097;
|
||||
let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let y = yoe + era * 400;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let day = (doy - (153 * mp + 2) / 5 + 1) as u16;
|
||||
let month = if mp < 10 { mp + 3 } else { mp - 9 } as u16;
|
||||
(y + i64::from(month <= 2), month, day)
|
||||
}
|
||||
|
||||
/// Windows:打开 stderr 的 VT 处理。
|
||||
#[cfg(windows)]
|
||||
fn enable_vt() {
|
||||
#[link(name = "kernel32")]
|
||||
unsafe extern "system" {
|
||||
fn GetStdHandle(which: u32) -> *mut core::ffi::c_void;
|
||||
fn GetConsoleMode(handle: *mut core::ffi::c_void, mode: *mut u32) -> i32;
|
||||
fn SetConsoleMode(handle: *mut core::ffi::c_void, mode: u32) -> i32;
|
||||
}
|
||||
|
||||
/// STD_ERROR_HANDLE:-12 的无符号写法。
|
||||
const STD_ERROR_HANDLE: u32 = 0xFFFF_FFF4;
|
||||
/// 让控制台认识 ANSI 转义序列。
|
||||
const ENABLE_VIRTUAL_TERMINAL_PROCESSING: u32 = 0x0004;
|
||||
|
||||
unsafe {
|
||||
let handle = GetStdHandle(STD_ERROR_HANDLE);
|
||||
let mut mode = 0u32;
|
||||
if GetConsoleMode(handle, &mut mode) != 0 {
|
||||
let _ = SetConsoleMode(handle, mode | ENABLE_VIRTUAL_TERMINAL_PROCESSING);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 非 Windows:不用做任何事。
|
||||
#[cfg(not(windows))]
|
||||
fn enable_vt() {}
|
||||
|
||||
/// 打一条 debug 日志(关掉时只花一次原子读)。
|
||||
#[macro_export]
|
||||
macro_rules! log_debug {
|
||||
(core, $($arg:tt)*) => {
|
||||
$crate::log_line!($crate::log::Channel::Core, $crate::log::Level::Debug, $($arg)*)
|
||||
};
|
||||
(pipe, $($arg:tt)*) => {
|
||||
$crate::log_line!($crate::log::Channel::Pipe, $crate::log::Level::Debug, $($arg)*)
|
||||
};
|
||||
}
|
||||
|
||||
/// 打一条 info 日志。
|
||||
#[macro_export]
|
||||
macro_rules! log_info {
|
||||
(core, $($arg:tt)*) => {
|
||||
$crate::log_line!($crate::log::Channel::Core, $crate::log::Level::Info, $($arg)*)
|
||||
};
|
||||
(pipe, $($arg:tt)*) => {
|
||||
$crate::log_line!($crate::log::Channel::Pipe, $crate::log::Level::Info, $($arg)*)
|
||||
};
|
||||
}
|
||||
|
||||
/// 打一条 warn 日志。
|
||||
#[macro_export]
|
||||
macro_rules! log_warn {
|
||||
(core, $($arg:tt)*) => {
|
||||
$crate::log_line!($crate::log::Channel::Core, $crate::log::Level::Warn, $($arg)*)
|
||||
};
|
||||
(pipe, $($arg:tt)*) => {
|
||||
$crate::log_line!($crate::log::Channel::Pipe, $crate::log::Level::Warn, $($arg)*)
|
||||
};
|
||||
}
|
||||
|
||||
/// 打一条 error 日志。
|
||||
#[macro_export]
|
||||
macro_rules! log_error {
|
||||
(core, $($arg:tt)*) => {
|
||||
$crate::log_line!($crate::log::Channel::Core, $crate::log::Level::Error, $($arg)*)
|
||||
};
|
||||
(pipe, $($arg:tt)*) => {
|
||||
$crate::log_line!($crate::log::Channel::Pipe, $crate::log::Level::Error, $($arg)*)
|
||||
};
|
||||
}
|
||||
|
||||
/// 上面四个宏的公共部分:先看等级,再看要不要真的写。内部用,别直接调。
|
||||
#[doc(hidden)]
|
||||
#[macro_export]
|
||||
macro_rules! log_line {
|
||||
($chan:expr, $lvl:expr, $($arg:tt)*) => {
|
||||
if $crate::log::enabled($lvl) {
|
||||
$crate::log::write($chan, $lvl, format_args!($($arg)*))
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// 天数换算日期:几个已知点 + 两次闰年的 2 月 29 日。
|
||||
#[test]
|
||||
fn civil_from_days_known_dates() {
|
||||
assert_eq!(civil_from_days(0), (1970, 1, 1));
|
||||
assert_eq!(civil_from_days(10_957), (2000, 1, 1));
|
||||
assert_eq!(civil_from_days(11_016), (2000, 2, 29));
|
||||
assert_eq!(civil_from_days(19_723), (2024, 1, 1));
|
||||
assert_eq!(civil_from_days(19_782), (2024, 2, 29));
|
||||
}
|
||||
|
||||
/// 行格式:等级 + 本地日期时间 + 来源 + 消息,等级该粗的粗、该色的色。
|
||||
#[test]
|
||||
fn render_has_time_channel_and_colors() {
|
||||
let stamp = Stamp {
|
||||
year: 2026,
|
||||
month: 9,
|
||||
day: 24,
|
||||
hour: 13,
|
||||
minute: 45,
|
||||
second: 12,
|
||||
milli: 130,
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
render(Level::Error, Channel::Pipe, true, &stamp, "boom"),
|
||||
"[\u{1b}[1m\u{1b}[31mERROR\u{1b}[0m 2026-09-24 13:45:12.130] [pipe] boom"
|
||||
);
|
||||
assert_eq!(
|
||||
render(Level::Warn, Channel::Core, true, &stamp, "careful"),
|
||||
"[\u{1b}[1m\u{1b}[38;5;208mWARN \u{1b}[0m 2026-09-24 13:45:12.130] [core] careful"
|
||||
);
|
||||
assert_eq!(
|
||||
render(Level::Debug, Channel::Pipe, true, &stamp, "detail"),
|
||||
"[\u{1b}[1mDEBUG\u{1b}[0m 2026-09-24 13:45:12.130] [pipe] detail"
|
||||
);
|
||||
assert_eq!(
|
||||
render(Level::Info, Channel::Core, true, &stamp, "hello"),
|
||||
"[INFO 2026-09-24 13:45:12.130] [core] hello"
|
||||
);
|
||||
}
|
||||
|
||||
/// 关掉颜色时整行必须是纯文本(重定向到文件时用)。
|
||||
#[test]
|
||||
fn render_without_color_is_plain() {
|
||||
let stamp = Stamp {
|
||||
year: 2026,
|
||||
month: 9,
|
||||
day: 24,
|
||||
hour: 1,
|
||||
minute: 2,
|
||||
second: 3,
|
||||
milli: 4,
|
||||
};
|
||||
assert_eq!(
|
||||
render(Level::Error, Channel::Core, false, &stamp, "boom"),
|
||||
"[ERROR 2026-09-24 01:02:03.004] [core] boom"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
use std::fs::{File, OpenOptions};
|
||||
use std::io::{self, Read, Seek, SeekFrom, Write};
|
||||
use std::path::Path;
|
||||
|
||||
// 页管理:数据库文件按固定大小的页组织,页大小由配置给(默认 4096,见 config.rs)。
|
||||
// 只负责"按页号读 / 写",不理解页里的内容 —— 内容怎么解释由上层(堆表 / B+ 树 / WAL)决定。
|
||||
// 页号从 0 开始,第 n 页在文件中的偏移是 n * page_size。
|
||||
|
||||
/// 页号:从 0 开始。u64 足够大,不用担心中途用完。
|
||||
pub type PageId = u64;
|
||||
|
||||
/// 一页:固定 page_size 字节,外加自己的页号。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Page {
|
||||
pub id: PageId,
|
||||
pub data: Vec<u8>,
|
||||
}
|
||||
|
||||
impl Page {
|
||||
/// 新建一页,内容全 0。
|
||||
pub fn new(id: PageId, size: usize) -> Self {
|
||||
Page {
|
||||
id,
|
||||
data: vec![0u8; size],
|
||||
}
|
||||
}
|
||||
|
||||
/// 清零。复用时先清空,避免残留旧数据。
|
||||
pub fn zero(&mut self) {
|
||||
self.data.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
/// 页文件:负责把 Page 落到磁盘上(不做缓存,每次读写都直接 syscall;缓冲池是后面的阶段)。
|
||||
#[derive(Debug)]
|
||||
pub struct PageFile {
|
||||
file: File,
|
||||
page_size: usize,
|
||||
}
|
||||
|
||||
impl PageFile {
|
||||
/// 打开(或创建)一个页文件;页大小由配置传进来。
|
||||
pub fn open<P: AsRef<Path>>(path: P, page_size: usize) -> io::Result<Self> {
|
||||
let file = OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(false)
|
||||
.open(path)?;
|
||||
Ok(PageFile { file, page_size })
|
||||
}
|
||||
|
||||
/// 这个文件的页大小。
|
||||
pub fn page_size(&self) -> usize {
|
||||
self.page_size
|
||||
}
|
||||
|
||||
/// 读取指定页号的页;页号超出文件末尾时返回 UnexpectedEof。
|
||||
pub fn read_page(&mut self, id: PageId) -> io::Result<Page> {
|
||||
let mut data = vec![0u8; self.page_size];
|
||||
self.file.seek(SeekFrom::Start(id * self.page_size as u64))?;
|
||||
self.file.read_exact(&mut data)?;
|
||||
Ok(Page { id, data })
|
||||
}
|
||||
|
||||
/// 写入一页。若页号超出当前文件末尾,文件会自动扩展。
|
||||
pub fn write_page(&mut self, page: &Page) -> io::Result<()> {
|
||||
if page.data.len() != self.page_size {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
format!(
|
||||
"page has {} bytes, this file uses {}",
|
||||
page.data.len(),
|
||||
self.page_size
|
||||
),
|
||||
));
|
||||
}
|
||||
self.file
|
||||
.seek(SeekFrom::Start(page.id * self.page_size as u64))?;
|
||||
self.file.write_all(&page.data)?;
|
||||
self.file.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 在文件末尾追加一页,返回新页的页号。
|
||||
pub fn append_page(&mut self) -> io::Result<Page> {
|
||||
let id = self.num_pages()?;
|
||||
let page = Page::new(id, self.page_size);
|
||||
self.write_page(&page)?;
|
||||
Ok(page)
|
||||
}
|
||||
|
||||
/// 当前文件里有多少页;长度不是页大小整数倍,说明页大小和文件对不上。
|
||||
pub fn num_pages(&mut self) -> io::Result<PageId> {
|
||||
let len = self.file.metadata()?.len();
|
||||
let size = self.page_size as u64;
|
||||
if len % size != 0 {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!(
|
||||
"file length {} is not a multiple of page size {} (did page.size change?)",
|
||||
len, size
|
||||
),
|
||||
));
|
||||
}
|
||||
Ok(len / size)
|
||||
}
|
||||
|
||||
/// 强制把文件缓冲刷到磁盘。写入关键路径时用。
|
||||
pub fn sync(&mut self) -> io::Result<()> {
|
||||
self.file.sync_all()
|
||||
}
|
||||
|
||||
/// 清空文件,游标归零。
|
||||
pub fn truncate(&mut self) -> io::Result<()> {
|
||||
self.file.set_len(0)?;
|
||||
self.file.seek(SeekFrom::Start(0))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
// 协议定义:Haskell ↔ Rust 之间那条"行分隔 JSON"消息的类型(请求 / 响应)。
|
||||
|
||||
/// 一行:列名到 JSON 值的映射。
|
||||
pub type Row = HashMap<String, serde_json::Value>;
|
||||
|
||||
/// 列类型;跟 Haskell 侧 TInt / TStr / TBool 对应。
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ColumnType {
|
||||
Int,
|
||||
Str,
|
||||
Bool,
|
||||
}
|
||||
|
||||
/// 一列的 schema。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct SchemaColumn {
|
||||
pub name: String,
|
||||
pub ty: ColumnType,
|
||||
}
|
||||
|
||||
/// 请求;method 字段做 tag。
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(tag = "method", rename_all = "snake_case")]
|
||||
pub enum Request {
|
||||
Ping,
|
||||
Scan { table: String },
|
||||
Insert {
|
||||
table: String,
|
||||
row: Row,
|
||||
#[serde(default)]
|
||||
key: Option<i64>,
|
||||
},
|
||||
LookupByIndex { table: String, key: i64 },
|
||||
ReplaceAll { table: String, rows: Vec<Row> },
|
||||
ListTables,
|
||||
DescribeTable { table: String },
|
||||
}
|
||||
|
||||
/// 响应;status 字段做 tag。
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(tag = "status", rename_all = "lowercase")]
|
||||
pub enum Response {
|
||||
Pong,
|
||||
Rows { rows: Vec<Row> },
|
||||
Tables { tables: Vec<String> },
|
||||
Schema { columns: Vec<SchemaColumn> },
|
||||
Ok,
|
||||
Error { message: String },
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
//! 简化版 WAL:写前日志 + 崩溃恢复。
|
||||
//!
|
||||
//! 条目格式:
|
||||
//! [u32 body_len][body]
|
||||
//! body = [u64 LSN][u8 操作类型][u16 表名长度][表名字节][JSON 载荷]
|
||||
//!
|
||||
//! 简化版约束:
|
||||
//! - 最多只有一条未完成的操作。写成功就清空 WAL。
|
||||
//! - 不做 checkpoint / redo / undo / 事务。
|
||||
//!
|
||||
//! 崩溃恢复:
|
||||
//! - 启动时若 WAL 非空,重放那一条,然后清空。
|
||||
|
||||
use std::fs::{File, OpenOptions};
|
||||
use std::io::{self, Read, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::protocol::Row;
|
||||
|
||||
/// 操作类型标记。
|
||||
const OP_INSERT: u8 = 1;
|
||||
const OP_REPLACE_ALL: u8 = 2;
|
||||
|
||||
/// 一条 WAL 操作。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum WalOp {
|
||||
Insert {
|
||||
table: String,
|
||||
row: Row,
|
||||
key: Option<i64>,
|
||||
},
|
||||
ReplaceAll {
|
||||
table: String,
|
||||
rows: Vec<Row>,
|
||||
},
|
||||
}
|
||||
|
||||
/// WAL 文件句柄。
|
||||
pub struct Wal {
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
impl Wal {
|
||||
/// 绑定到 WAL 文件路径;首次 append 时自动创建文件。
|
||||
pub fn new<P: AsRef<Path>>(path: P) -> Self {
|
||||
Wal {
|
||||
path: path.as_ref().to_path_buf(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 日志文件路径。
|
||||
pub fn path(&self) -> &Path {
|
||||
&self.path
|
||||
}
|
||||
|
||||
/// 清空 WAL;文件不存在则什么都不做。
|
||||
pub fn clear(&self) -> io::Result<()> {
|
||||
if self.path.exists() {
|
||||
let f = OpenOptions::new().write(true).open(&self.path)?;
|
||||
f.set_len(0)?;
|
||||
f.sync_all()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 追加一条操作并 fsync。
|
||||
pub fn append(&self, op: &WalOp) -> io::Result<()> {
|
||||
let bytes = encode(op)?;
|
||||
let mut f = OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&self.path)?;
|
||||
f.write_all(&bytes)?;
|
||||
f.sync_all()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 读取当前 WAL;空文件 / 不存在返回 None。
|
||||
pub fn read(&self) -> io::Result<Option<WalOp>> {
|
||||
if !self.path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut f = File::open(&self.path)?;
|
||||
let mut buf = Vec::new();
|
||||
f.read_to_end(&mut buf)?;
|
||||
if buf.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
match decode(&buf) {
|
||||
Ok(opt) => Ok(opt),
|
||||
Err(e) => Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!("corrupt WAL: {}", e),
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 把一条操作编成字节。
|
||||
fn encode(op: &WalOp) -> io::Result<Vec<u8>> {
|
||||
// 先编出 (类型, 表名, 载荷)
|
||||
let (op_type, table, payload) = match op {
|
||||
WalOp::Insert { table, row, key } => {
|
||||
let p = serde_json::to_vec(&(row, key)).map_err(io::Error::other)?;
|
||||
(OP_INSERT, table.clone(), p)
|
||||
}
|
||||
WalOp::ReplaceAll { table, rows } => {
|
||||
let p = serde_json::to_vec(rows).map_err(io::Error::other)?;
|
||||
(OP_REPLACE_ALL, table.clone(), p)
|
||||
}
|
||||
};
|
||||
|
||||
let table_bytes = table.as_bytes();
|
||||
|
||||
// body = [u64 LSN][u8 类型][u16 表名长度][表名][载荷]
|
||||
// 简化版 LSN 固定为 1;完整版会改成真正递增。
|
||||
let lsn: u64 = 1;
|
||||
let mut body = Vec::with_capacity(8 + 1 + 2 + table_bytes.len() + payload.len());
|
||||
body.extend_from_slice(&lsn.to_le_bytes());
|
||||
body.push(op_type);
|
||||
body.extend_from_slice(&(table_bytes.len() as u16).to_le_bytes());
|
||||
body.extend_from_slice(table_bytes);
|
||||
body.extend_from_slice(&payload);
|
||||
|
||||
// 整条 = [u32 body_len][body]
|
||||
let mut out = Vec::with_capacity(4 + body.len());
|
||||
out.extend_from_slice(&(body.len() as u32).to_le_bytes());
|
||||
out.extend_from_slice(&body);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// 解析一条 WAL 条目。
|
||||
///
|
||||
/// - `Ok(Some(op))`:成功解析出操作
|
||||
/// - `Ok(None)`:尾部被截断(写入一半就断电),按"没有这条"处理
|
||||
/// - `Err(_)`:字节流本身损坏,抛错
|
||||
fn decode(buf: &[u8]) -> Result<Option<WalOp>, String> {
|
||||
if buf.len() < 4 {
|
||||
return Ok(None);
|
||||
}
|
||||
let body_len = u32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]]) as usize;
|
||||
if buf.len() < 4 + body_len {
|
||||
// 尾部被截断——忽略
|
||||
return Ok(None);
|
||||
}
|
||||
let body = &buf[4..4 + body_len];
|
||||
if body.len() < 8 + 1 + 2 {
|
||||
return Err("body too short".into());
|
||||
}
|
||||
// LSN 简化版忽略
|
||||
let _lsn = u64::from_le_bytes([
|
||||
body[0], body[1], body[2], body[3], body[4], body[5], body[6], body[7],
|
||||
]);
|
||||
let op_type = body[8];
|
||||
let table_len = u16::from_le_bytes([body[9], body[10]]) as usize;
|
||||
if body.len() < 11 + table_len {
|
||||
return Err("table name truncated".into());
|
||||
}
|
||||
let table = String::from_utf8(body[11..11 + table_len].to_vec())
|
||||
.map_err(|e| format!("bad utf8 table name: {}", e))?;
|
||||
let payload = &body[11 + table_len..];
|
||||
|
||||
let op = match op_type {
|
||||
OP_INSERT => {
|
||||
let (row, key): (Row, Option<i64>) = serde_json::from_slice(payload)
|
||||
.map_err(|e| format!("bad insert payload: {}", e))?;
|
||||
WalOp::Insert { table, row, key }
|
||||
}
|
||||
OP_REPLACE_ALL => {
|
||||
let rows: Vec<Row> = serde_json::from_slice(payload)
|
||||
.map_err(|e| format!("bad replace_all payload: {}", e))?;
|
||||
WalOp::ReplaceAll { table, rows }
|
||||
}
|
||||
other => return Err(format!("unknown op type: {}", other)),
|
||||
};
|
||||
Ok(Some(op))
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
use chusql_storage::btree::DiskBTree;
|
||||
use chusql_storage::config::{DEFAULT_BTREE_ORDER, DEFAULT_PAGE_SIZE};
|
||||
|
||||
// B+ 树测试:空树查询、插入后查找、重开文件数据仍在、覆盖同一个键、升序遍历、
|
||||
// 倒序插入 500 个键、页大小 / order 可以配置、配置和文件头对不上时报错。
|
||||
|
||||
/// 空树的 get 返回 None,遍历返回空。
|
||||
#[test]
|
||||
fn empty_tree_get() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut t = DiskBTree::open(
|
||||
dir.path().join("t.idx"),
|
||||
DEFAULT_PAGE_SIZE,
|
||||
DEFAULT_BTREE_ORDER,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(t.get(1).unwrap(), None);
|
||||
assert_eq!(t.iter_all().unwrap(), vec![]);
|
||||
}
|
||||
|
||||
/// 插 100 个键,每个都能按 key 查回来。
|
||||
#[test]
|
||||
fn insert_then_get() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut t = DiskBTree::open(
|
||||
dir.path().join("t.idx"),
|
||||
DEFAULT_PAGE_SIZE,
|
||||
DEFAULT_BTREE_ORDER,
|
||||
)
|
||||
.unwrap();
|
||||
for i in 0..100 {
|
||||
t.insert(i, i as u64 * 10).unwrap();
|
||||
}
|
||||
for i in 0..100 {
|
||||
assert_eq!(t.get(i).unwrap(), Some(i as u64 * 10));
|
||||
}
|
||||
}
|
||||
|
||||
/// 关掉再打开同一个索引文件,数据还在。
|
||||
#[test]
|
||||
fn reopen_persists() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("t.idx");
|
||||
{
|
||||
let mut t = DiskBTree::open(&path, DEFAULT_PAGE_SIZE, DEFAULT_BTREE_ORDER).unwrap();
|
||||
for i in 0..50 {
|
||||
t.insert(i, i as u64).unwrap();
|
||||
}
|
||||
}
|
||||
let mut t = DiskBTree::open(&path, DEFAULT_PAGE_SIZE, DEFAULT_BTREE_ORDER).unwrap();
|
||||
for i in 0..50 {
|
||||
assert_eq!(t.get(i).unwrap(), Some(i as u64));
|
||||
}
|
||||
}
|
||||
|
||||
/// 同一个键插两次,后写的值生效。
|
||||
#[test]
|
||||
fn update_existing() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut t = DiskBTree::open(
|
||||
dir.path().join("t.idx"),
|
||||
DEFAULT_PAGE_SIZE,
|
||||
DEFAULT_BTREE_ORDER,
|
||||
)
|
||||
.unwrap();
|
||||
t.insert(5, 100).unwrap();
|
||||
t.insert(5, 200).unwrap();
|
||||
assert_eq!(t.get(5).unwrap(), Some(200));
|
||||
}
|
||||
|
||||
/// 倒序插入后遍历,结果仍按 key 升序。
|
||||
#[test]
|
||||
fn iter_all_sorted() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut t = DiskBTree::open(
|
||||
dir.path().join("t.idx"),
|
||||
DEFAULT_PAGE_SIZE,
|
||||
DEFAULT_BTREE_ORDER,
|
||||
)
|
||||
.unwrap();
|
||||
for i in (0..50).rev() {
|
||||
t.insert(i, i as u64).unwrap();
|
||||
}
|
||||
let all = t.iter_all().unwrap();
|
||||
assert_eq!(all.len(), 50);
|
||||
for (i, (k, _)) in all.iter().enumerate() {
|
||||
assert_eq!(*k, i as i64);
|
||||
}
|
||||
}
|
||||
|
||||
/// 倒序插入 500 个键(必然多次分裂),每个键都能查到。
|
||||
#[test]
|
||||
fn reverse_order_insert() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut t = DiskBTree::open(
|
||||
dir.path().join("t.idx"),
|
||||
DEFAULT_PAGE_SIZE,
|
||||
DEFAULT_BTREE_ORDER,
|
||||
)
|
||||
.unwrap();
|
||||
for i in (0..500).rev() {
|
||||
t.insert(i, i as u64).unwrap();
|
||||
}
|
||||
for i in 0..500 {
|
||||
assert_eq!(t.get(i).unwrap(), Some(i as u64));
|
||||
}
|
||||
}
|
||||
|
||||
/// 页大小和 order 都可以配置:512 字节的页 + 8 个孩子照样能插能查。
|
||||
#[test]
|
||||
fn custom_page_size_and_order() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut t = DiskBTree::open(dir.path().join("t.idx"), 512, 8).unwrap();
|
||||
for i in 0..200 {
|
||||
t.insert(i, i as u64 + 1).unwrap();
|
||||
}
|
||||
for i in 0..200 {
|
||||
assert_eq!(t.get(i).unwrap(), Some(i as u64 + 1));
|
||||
}
|
||||
}
|
||||
|
||||
/// 文件头里记着页大小和 order:换配置再打开要报错,而不是读出乱七八糟的东西。
|
||||
#[test]
|
||||
fn config_mismatch_errors() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("t.idx");
|
||||
{
|
||||
let mut t = DiskBTree::open(&path, DEFAULT_PAGE_SIZE, DEFAULT_BTREE_ORDER).unwrap();
|
||||
t.insert(1, 1).unwrap();
|
||||
}
|
||||
assert!(DiskBTree::open(&path, DEFAULT_PAGE_SIZE, 8).is_err());
|
||||
assert!(DiskBTree::open(&path, 8192, DEFAULT_BTREE_ORDER).is_err());
|
||||
assert!(DiskBTree::open(&path, DEFAULT_PAGE_SIZE, DEFAULT_BTREE_ORDER).is_ok());
|
||||
}
|
||||
|
||||
/// 不是本项目的索引文件(比如随便一个空文件)要报错。
|
||||
#[test]
|
||||
fn not_an_index_file_errors() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("junk.idx");
|
||||
std::fs::write(&path, vec![7u8; DEFAULT_PAGE_SIZE]).unwrap();
|
||||
assert!(DiskBTree::open(&path, DEFAULT_PAGE_SIZE, DEFAULT_BTREE_ORDER).is_err());
|
||||
}
|
||||
|
||||
/// order 放不进一页时直接拒绝。
|
||||
#[test]
|
||||
fn order_too_large_for_page_errors() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
assert!(DiskBTree::open(dir.path().join("t.idx"), 512, 300).is_err());
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
use chusql_storage::catalog::Catalog;
|
||||
use chusql_storage::protocol::{ColumnType, Row};
|
||||
use serde_json::json;
|
||||
|
||||
fn row(pairs: &[(&str, serde_json::Value)]) -> Row {
|
||||
let mut m = Row::new();
|
||||
for (k, v) in pairs {
|
||||
m.insert((*k).to_string(), v.clone());
|
||||
}
|
||||
m
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_file_gives_empty_catalog() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("catalog.json");
|
||||
let c = Catalog::load(&path).unwrap();
|
||||
assert_eq!(c.table_names().len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_then_describe() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("catalog.json");
|
||||
|
||||
let mut c = Catalog::load(&path).unwrap();
|
||||
c.ensure_columns("users", &row(&[("id", json!(1)), ("name", json!("Alice"))]));
|
||||
c.save(&path).unwrap();
|
||||
|
||||
let c2 = Catalog::load(&path).unwrap();
|
||||
let s = c2.describe("users").unwrap();
|
||||
assert_eq!(s.columns.len(), 2);
|
||||
let names: Vec<&str> = s.columns.iter().map(|c| c.name.as_str()).collect();
|
||||
assert!(names.contains(&"id"));
|
||||
assert!(names.contains(&"name"));
|
||||
let id_col = s.columns.iter().find(|c| c.name == "id").unwrap();
|
||||
assert_eq!(id_col.ty, ColumnType::Int);
|
||||
let name_col = s.columns.iter().find(|c| c.name == "name").unwrap();
|
||||
assert_eq!(name_col.ty, ColumnType::Str);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn second_insert_does_not_duplicate_columns() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("catalog.json");
|
||||
|
||||
let mut c = Catalog::load(&path).unwrap();
|
||||
c.ensure_columns("users", &row(&[("id", json!(1))]));
|
||||
c.ensure_columns("users", &row(&[("id", json!(2))]));
|
||||
assert_eq!(c.describe("users").unwrap().columns.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn describe_unknown_returns_none() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("catalog.json");
|
||||
let c = Catalog::load(&path).unwrap();
|
||||
assert!(c.describe("nope").is_none());
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use chusql_storage::config::{
|
||||
self, Config, DEFAULT_BTREE_ORDER, DEFAULT_DATA_DIR, DEFAULT_PAGE_SIZE, DEFAULT_PIPE_NAME,
|
||||
Origin,
|
||||
};
|
||||
use chusql_storage::log::Level;
|
||||
|
||||
// 配置测试:没有文件时全默认、文件里写了才覆盖、环境变量优先于文件、
|
||||
// 非法值 / 未知键要报错、每个选项的来源都记得下来。
|
||||
|
||||
/// 没有配置文件、没有环境变量:全部走内置默认值。
|
||||
#[test]
|
||||
fn defaults_without_file() {
|
||||
let loaded = config::resolve(None, None, &|_| None).unwrap();
|
||||
assert_eq!(loaded.config, Config::default());
|
||||
assert!(loaded.config_path.is_none());
|
||||
assert!(loaded.origins.iter().all(|(_, _, o)| *o == Origin::Default));
|
||||
assert_eq!(loaded.config.page_size, DEFAULT_PAGE_SIZE);
|
||||
assert_eq!(loaded.config.btree_order, DEFAULT_BTREE_ORDER);
|
||||
assert_eq!(loaded.config.data_dir, PathBuf::from(DEFAULT_DATA_DIR));
|
||||
assert_eq!(loaded.config.pipe_name, DEFAULT_PIPE_NAME);
|
||||
assert_eq!(loaded.config.log_level, Level::Info);
|
||||
}
|
||||
|
||||
/// 文件里只写了一项:这一项生效,其余回退默认(并且来源记得清清楚楚)。
|
||||
#[test]
|
||||
fn file_overrides_only_written_keys() {
|
||||
let path = PathBuf::from("t.toml");
|
||||
let loaded = config::resolve(Some("[page]\nsize = 8192\n"), Some(path.clone()), &|_| None)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(loaded.config.page_size, 8192);
|
||||
assert_eq!(loaded.config.btree_order, DEFAULT_BTREE_ORDER);
|
||||
assert_eq!(loaded.config.data_dir, PathBuf::from(DEFAULT_DATA_DIR));
|
||||
assert_eq!(loaded.config_path, Some(path.clone()));
|
||||
|
||||
let page = loaded.origins.iter().find(|(n, _, _)| *n == "page.size").unwrap();
|
||||
assert_eq!(page.2, Origin::File(path));
|
||||
let order = loaded.origins.iter().find(|(n, _, _)| *n == "btree.order").unwrap();
|
||||
assert_eq!(order.2, Origin::Default);
|
||||
}
|
||||
|
||||
/// 五项全写:值都生效,来源都是文件。
|
||||
#[test]
|
||||
fn file_all_keys() {
|
||||
let text = "[page]\nsize = 8192\n[btree]\norder = 32\n[storage]\ndata_dir = \"mydata\"\n[server]\npipe_name = \"mypipe\"\n[log]\nlevel = \"debug\"\n";
|
||||
let path = PathBuf::from("t.toml");
|
||||
let loaded = config::resolve(Some(text), Some(path.clone()), &|_| None).unwrap();
|
||||
|
||||
assert_eq!(loaded.config.page_size, 8192);
|
||||
assert_eq!(loaded.config.btree_order, 32);
|
||||
assert_eq!(loaded.config.data_dir, PathBuf::from("mydata"));
|
||||
assert_eq!(loaded.config.pipe_name, "mypipe");
|
||||
assert_eq!(loaded.config.log_level, Level::Debug);
|
||||
assert!(
|
||||
loaded
|
||||
.origins
|
||||
.iter()
|
||||
.all(|(_, _, o)| *o == Origin::File(path.clone()))
|
||||
);
|
||||
}
|
||||
|
||||
/// 环境变量优先于文件。
|
||||
#[test]
|
||||
fn env_beats_file() {
|
||||
let text = "[storage]\ndata_dir = \"from-file\"\n";
|
||||
let env = |k: &str| (k == "CHUSQL_DATA_DIR").then(|| "from-env".to_string());
|
||||
let loaded = config::resolve(Some(text), Some(PathBuf::from("t.toml")), &env).unwrap();
|
||||
|
||||
assert_eq!(loaded.config.data_dir, PathBuf::from("from-env"));
|
||||
let dir = loaded
|
||||
.origins
|
||||
.iter()
|
||||
.find(|(n, _, _)| *n == "storage.data_dir")
|
||||
.unwrap();
|
||||
assert_eq!(dir.2, Origin::Env("CHUSQL_DATA_DIR"));
|
||||
}
|
||||
|
||||
/// 日志等级写错要报错,而不是悄悄按默认跑。
|
||||
#[test]
|
||||
fn rejects_bad_log_level() {
|
||||
let err = config::resolve(Some("[log]\nlevel = \"loud\"\n"), None, &|_| None).unwrap_err();
|
||||
assert!(err.contains("log.level"), "got {}", err);
|
||||
}
|
||||
|
||||
/// 页大小必须是 512..65536 之间的 2 的幂。
|
||||
#[test]
|
||||
fn rejects_bad_page_size() {
|
||||
let err = config::resolve(Some("[page]\nsize = 3000\n"), None, &|_| None).unwrap_err();
|
||||
assert!(err.contains("page.size"), "got {}", err);
|
||||
|
||||
let err = config::resolve(Some("[page]\nsize = 128\n"), None, &|_| None).unwrap_err();
|
||||
assert!(err.contains("page.size"), "got {}", err);
|
||||
}
|
||||
|
||||
/// order 太小、或者放不进一页,都要报错。
|
||||
#[test]
|
||||
fn rejects_bad_btree_order() {
|
||||
let err = config::resolve(Some("[btree]\norder = 2\n"), None, &|_| None).unwrap_err();
|
||||
assert!(err.contains("btree.order"), "got {}", err);
|
||||
|
||||
let err = config::resolve(Some("[btree]\norder = 300\n"), None, &|_| None).unwrap_err();
|
||||
assert!(err.contains("does not fit"), "got {}", err);
|
||||
}
|
||||
|
||||
/// 配置里出现认不出来的键要报错(拼错了不能当没看见)。
|
||||
#[test]
|
||||
fn rejects_unknown_key() {
|
||||
let err = config::resolve(Some("[page]\nsizes = 4096\n"), None, &|_| None).unwrap_err();
|
||||
assert!(err.contains("bad config"), "got {}", err);
|
||||
}
|
||||
|
||||
/// 环境变量给了非法值也要报错。
|
||||
#[test]
|
||||
fn rejects_bad_env_value() {
|
||||
let env = |k: &str| (k == "CHUSQL_PAGE_SIZE").then(|| "big".to_string());
|
||||
let err = config::resolve(None, None, &env).unwrap_err();
|
||||
assert!(err.contains("CHUSQL_PAGE_SIZE"), "got {}", err);
|
||||
}
|
||||
|
||||
/// 来源文案要能说清"哪来的"。
|
||||
#[test]
|
||||
fn origin_description() {
|
||||
assert_eq!(Origin::Default.describe(), "default");
|
||||
assert_eq!(Origin::Env("CHUSQL_PIPE").describe(), "env CHUSQL_PIPE");
|
||||
assert_eq!(
|
||||
Origin::File(PathBuf::from("a.toml")).describe(),
|
||||
format!("file {}", PathBuf::from("a.toml").display())
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
use chusql_storage::config::DEFAULT_PAGE_SIZE;
|
||||
use chusql_storage::heap::HeapTable;
|
||||
use chusql_storage::protocol::Row;
|
||||
use serde_json::json;
|
||||
|
||||
// 堆表测试:插入 + 全表扫描、跨页多行、大行、超大行报错、重开文件后数据还在、自定义页大小。
|
||||
|
||||
/// 造一行测试数据(列名 → JSON 值)。
|
||||
fn row(pairs: &[(&str, serde_json::Value)]) -> Row {
|
||||
let mut m = Row::new();
|
||||
for (k, v) in pairs {
|
||||
m.insert((*k).to_string(), v.clone());
|
||||
}
|
||||
m
|
||||
}
|
||||
|
||||
/// 插一行再扫描,能原样读回来。
|
||||
#[test]
|
||||
fn insert_and_scan_one_row() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("t.db");
|
||||
let mut t = HeapTable::open(&path, DEFAULT_PAGE_SIZE).unwrap();
|
||||
|
||||
t.insert(&row(&[("id", json!(1)), ("name", json!("Alice"))]))
|
||||
.unwrap();
|
||||
|
||||
let rows = t.scan().unwrap();
|
||||
assert_eq!(rows.len(), 1);
|
||||
assert_eq!(rows[0]["id"], json!(1));
|
||||
assert_eq!(rows[0]["name"], json!("Alice"));
|
||||
}
|
||||
|
||||
/// 插 500 行(必然跨多页),扫描回来的行数和顺序都要对。
|
||||
#[test]
|
||||
fn insert_many_rows_cross_pages() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("t.db");
|
||||
let mut t = HeapTable::open(&path, DEFAULT_PAGE_SIZE).unwrap();
|
||||
|
||||
for i in 0..500 {
|
||||
t.insert(&row(&[("id", json!(i))])).unwrap();
|
||||
}
|
||||
|
||||
let rows = t.scan().unwrap();
|
||||
assert_eq!(rows.len(), 500);
|
||||
// 顺序保持
|
||||
for (i, r) in rows.iter().enumerate() {
|
||||
assert_eq!(r["id"], json!(i as i64));
|
||||
}
|
||||
}
|
||||
|
||||
/// 约 2KB 的大行也要能放进一页。
|
||||
#[test]
|
||||
fn large_row_still_fits() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("t.db");
|
||||
let mut t = HeapTable::open(&path, DEFAULT_PAGE_SIZE).unwrap();
|
||||
|
||||
let big = "x".repeat(2000);
|
||||
t.insert(&row(&[("data", json!(big.clone()))])).unwrap();
|
||||
|
||||
let rows = t.scan().unwrap();
|
||||
assert_eq!(rows.len(), 1);
|
||||
assert_eq!(rows[0]["data"], json!(big));
|
||||
}
|
||||
|
||||
/// 超过一页容量的行要报错,而不是悄悄写坏。
|
||||
#[test]
|
||||
fn row_too_large_errors() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("t.db");
|
||||
let mut t = HeapTable::open(&path, DEFAULT_PAGE_SIZE).unwrap();
|
||||
|
||||
let huge = "x".repeat(10_000);
|
||||
let err = t.insert(&row(&[("data", json!(huge))]));
|
||||
assert!(err.is_err());
|
||||
}
|
||||
|
||||
/// 关掉再重新打开同一个文件,之前插入的数据还在。
|
||||
#[test]
|
||||
fn reopen_and_scan() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("t.db");
|
||||
|
||||
{
|
||||
let mut t = HeapTable::open(&path, DEFAULT_PAGE_SIZE).unwrap();
|
||||
t.insert(&row(&[("id", json!(1))])).unwrap();
|
||||
t.insert(&row(&[("id", json!(2))])).unwrap();
|
||||
}
|
||||
|
||||
let mut t = HeapTable::open(&path, DEFAULT_PAGE_SIZE).unwrap();
|
||||
let rows = t.scan().unwrap();
|
||||
assert_eq!(rows.len(), 2);
|
||||
assert_eq!(rows[0]["id"], json!(1));
|
||||
assert_eq!(rows[1]["id"], json!(2));
|
||||
}
|
||||
|
||||
/// 页大小可以配置:512 字节的页装得下小行、放不下大行,行数不变。
|
||||
#[test]
|
||||
fn custom_page_size() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("small.db");
|
||||
let mut t = HeapTable::open(&path, 512).unwrap();
|
||||
|
||||
for i in 0..50 {
|
||||
t.insert(&row(&[("id", json!(i))])).unwrap();
|
||||
}
|
||||
assert_eq!(t.scan().unwrap().len(), 50);
|
||||
|
||||
let big = "x".repeat(600);
|
||||
assert!(t.insert(&row(&[("data", json!(big))])).is_err());
|
||||
}
|
||||
|
||||
/// 整表改写之后索引必须跟着重建:旧键查不到,新行按 id 能查到。
|
||||
#[test]
|
||||
fn replace_all_rebuilds_index() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut t = HeapTable::open_indexed(
|
||||
dir.path().join("t.db"),
|
||||
dir.path().join("t.idx"),
|
||||
DEFAULT_PAGE_SIZE,
|
||||
chusql_storage::config::DEFAULT_BTREE_ORDER,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
t.insert_keyed(&row(&[("id", json!(1)), ("name", json!("Alice"))]), 1)
|
||||
.unwrap();
|
||||
t.insert_keyed(&row(&[("id", json!(2)), ("name", json!("Bob"))]), 2)
|
||||
.unwrap();
|
||||
assert!(t.get_by_key(1).unwrap().is_some());
|
||||
|
||||
t.replace_all(&[
|
||||
row(&[("id", json!(7)), ("name", json!("Zoe"))]),
|
||||
row(&[("name", json!("NoId"))]),
|
||||
])
|
||||
.unwrap();
|
||||
|
||||
assert!(t.get_by_key(1).unwrap().is_none(), "旧键必须作废");
|
||||
assert!(t.get_by_key(2).unwrap().is_none());
|
||||
let got = t.get_by_key(7).unwrap().expect("新行应该按 id 建好索引");
|
||||
assert_eq!(got["name"], json!("Zoe"));
|
||||
assert_eq!(t.scan().unwrap().len(), 2);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
use chusql_storage::config::DEFAULT_PAGE_SIZE;
|
||||
use chusql_storage::page::{Page, PageFile};
|
||||
|
||||
// 页文件测试:一页的读写、页号越界报错、追加页让文件变长、多页互不干扰、覆写同一页、
|
||||
// 自定义页大小、页大小和文件对不上时报错。
|
||||
|
||||
/// 写入一页再读回来,内容和长度都应该一致。
|
||||
#[test]
|
||||
fn write_then_read_page() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("test.db");
|
||||
|
||||
let mut pf = PageFile::open(&path, DEFAULT_PAGE_SIZE).unwrap();
|
||||
|
||||
let mut p = Page::new(0, DEFAULT_PAGE_SIZE);
|
||||
p.data[0] = 42;
|
||||
p.data[DEFAULT_PAGE_SIZE - 1] = 7;
|
||||
pf.write_page(&p).unwrap();
|
||||
|
||||
let read = pf.read_page(0).unwrap();
|
||||
assert_eq!(read.data[0], 42);
|
||||
assert_eq!(read.data[DEFAULT_PAGE_SIZE - 1], 7);
|
||||
assert_eq!(read.data.len(), DEFAULT_PAGE_SIZE);
|
||||
}
|
||||
|
||||
/// 空文件读第 0 页应该报错。
|
||||
#[test]
|
||||
fn read_out_of_range_returns_error() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("test.db");
|
||||
|
||||
let mut pf = PageFile::open(&path, DEFAULT_PAGE_SIZE).unwrap();
|
||||
assert!(pf.read_page(0).is_err());
|
||||
}
|
||||
|
||||
/// 每 append 一页,num_pages 就加一。
|
||||
#[test]
|
||||
fn append_grows_file() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("test.db");
|
||||
|
||||
let mut pf = PageFile::open(&path, DEFAULT_PAGE_SIZE).unwrap();
|
||||
assert_eq!(pf.num_pages().unwrap(), 0);
|
||||
|
||||
pf.append_page().unwrap();
|
||||
assert_eq!(pf.num_pages().unwrap(), 1);
|
||||
|
||||
pf.append_page().unwrap();
|
||||
pf.append_page().unwrap();
|
||||
assert_eq!(pf.num_pages().unwrap(), 3);
|
||||
}
|
||||
|
||||
/// 页 0 和页 1 的内容互不影响。
|
||||
#[test]
|
||||
fn two_pages_independent() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("test.db");
|
||||
|
||||
let mut pf = PageFile::open(&path, DEFAULT_PAGE_SIZE).unwrap();
|
||||
|
||||
let mut a = Page::new(0, DEFAULT_PAGE_SIZE);
|
||||
a.data[0] = 1;
|
||||
let mut b = Page::new(1, DEFAULT_PAGE_SIZE);
|
||||
b.data[0] = 2;
|
||||
pf.write_page(&a).unwrap();
|
||||
pf.write_page(&b).unwrap();
|
||||
|
||||
assert_eq!(pf.read_page(0).unwrap().data[0], 1);
|
||||
assert_eq!(pf.read_page(1).unwrap().data[0], 2);
|
||||
}
|
||||
|
||||
/// 覆写同一页:读到的是新内容,页数不变。
|
||||
#[test]
|
||||
fn overwrite_page() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("test.db");
|
||||
|
||||
let mut pf = PageFile::open(&path, DEFAULT_PAGE_SIZE).unwrap();
|
||||
|
||||
let mut a = Page::new(0, DEFAULT_PAGE_SIZE);
|
||||
a.data[0] = 1;
|
||||
pf.write_page(&a).unwrap();
|
||||
|
||||
let mut b = Page::new(0, DEFAULT_PAGE_SIZE);
|
||||
b.data[0] = 99;
|
||||
pf.write_page(&b).unwrap();
|
||||
|
||||
assert_eq!(pf.read_page(0).unwrap().data[0], 99);
|
||||
assert_eq!(pf.num_pages().unwrap(), 1);
|
||||
}
|
||||
|
||||
/// 页大小可以配置:512 字节的页照样读写,文件按 512 字节对齐。
|
||||
#[test]
|
||||
fn custom_page_size() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("small.db");
|
||||
|
||||
let mut pf = PageFile::open(&path, 512).unwrap();
|
||||
assert_eq!(pf.page_size(), 512);
|
||||
|
||||
let mut p = Page::new(0, 512);
|
||||
p.data[511] = 9;
|
||||
pf.write_page(&p).unwrap();
|
||||
|
||||
assert_eq!(pf.read_page(0).unwrap().data[511], 9);
|
||||
assert_eq!(pf.num_pages().unwrap(), 1);
|
||||
assert_eq!(std::fs::metadata(&path).unwrap().len(), 512);
|
||||
}
|
||||
|
||||
/// 文件长度不是页大小整数倍(比如页大小被改过)要报错,而不是算出错误的页数。
|
||||
#[test]
|
||||
fn mismatched_file_length_errors() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("odd.db");
|
||||
std::fs::write(&path, vec![0u8; 100]).unwrap();
|
||||
|
||||
let mut pf = PageFile::open(&path, DEFAULT_PAGE_SIZE).unwrap();
|
||||
assert!(pf.num_pages().is_err());
|
||||
}
|
||||
|
||||
/// 页大小对不上的页不许写进去。
|
||||
#[test]
|
||||
fn write_wrong_sized_page_errors() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("test.db");
|
||||
|
||||
let mut pf = PageFile::open(&path, 512).unwrap();
|
||||
assert!(pf.write_page(&Page::new(0, 1024)).is_err());
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
use interprocess::local_socket::{
|
||||
prelude::*,
|
||||
GenericNamespaced
|
||||
};
|
||||
use interprocess::TryClone;
|
||||
|
||||
// 命名管道集成测试:起 server 子进程,用唯一管道名,测试之间互不干扰。
|
||||
|
||||
/// 生成唯一管道名,避免测试之间抢占。
|
||||
fn unique_pipe_name() -> String {
|
||||
let id = std::process::id();
|
||||
let ns = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
format!("chusql-test-{}-{}", id, ns)
|
||||
}
|
||||
|
||||
/// 持有 server 子进程,Drop 时杀掉。
|
||||
struct ServerProc(Child);
|
||||
|
||||
impl Drop for ServerProc {
|
||||
/// 测试结束时杀掉子进程并回收,避免留下孤儿进程。
|
||||
fn drop(&mut self) {
|
||||
let _ = self.0.kill();
|
||||
let _ = self.0.wait();
|
||||
}
|
||||
}
|
||||
|
||||
/// 启动 server 子进程,并等管道可连(最多 5 秒)。
|
||||
fn start_server(pipe: &str) -> (ServerProc, tempfile::TempDir) {
|
||||
let data = tempfile::tempdir().unwrap();
|
||||
|
||||
let child = Command::new(env!("CARGO_BIN_EXE_server"))
|
||||
.env("CHUSQL_PIPE", pipe)
|
||||
.env("CHUSQL_DATA_DIR", data.path())
|
||||
.env("CHUSQL_PAGE_SIZE", "4096")
|
||||
.env("CHUSQL_BTREE_ORDER", "4")
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.expect("failed to spawn server");
|
||||
|
||||
let srv = ServerProc(child);
|
||||
|
||||
for _ in 0..100 {
|
||||
if connect(pipe).is_ok() {
|
||||
return (srv, data);
|
||||
}
|
||||
thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
panic!("server did not start within 5s");
|
||||
}
|
||||
|
||||
/// 连到指定管道。
|
||||
fn connect(pipe: &str) -> std::io::Result<LocalSocketStream> {
|
||||
let name = pipe
|
||||
.to_ns_name::<GenericNamespaced>()
|
||||
.map_err(|e| std::io::Error::other(e.to_string()))?;
|
||||
LocalSocketStream::connect(name)
|
||||
}
|
||||
|
||||
/// 发一行请求,读一行响应。
|
||||
fn send(stream: &mut LocalSocketStream, line: &str) -> String {
|
||||
stream.write_all(line.as_bytes()).unwrap();
|
||||
stream.write_all(b"\n").unwrap();
|
||||
stream.flush().unwrap();
|
||||
|
||||
let mut reader = BufReader::new(stream.try_clone().unwrap());
|
||||
let mut buf = String::new();
|
||||
reader.read_line(&mut buf).unwrap();
|
||||
buf.trim().to_string()
|
||||
}
|
||||
|
||||
/// ping 应该回 pong。
|
||||
#[test]
|
||||
fn ping_pong() {
|
||||
let pipe = unique_pipe_name();
|
||||
let (_srv, _data) = start_server(&pipe);
|
||||
|
||||
let mut c = connect(&pipe).unwrap();
|
||||
assert_eq!(send(&mut c, r#"{"method":"ping"}"#), r#"{"status":"pong"}"#);
|
||||
}
|
||||
|
||||
/// 插一行再 scan,能扫到刚插进去的数据。
|
||||
#[test]
|
||||
fn insert_then_scan() {
|
||||
let pipe = unique_pipe_name();
|
||||
let (_srv, _data) = start_server(&pipe);
|
||||
let mut c = connect(&pipe).unwrap();
|
||||
|
||||
let r1 = send(
|
||||
&mut c,
|
||||
r#"{"method":"insert","table":"users","row":{"id":1,"name":"Alice"}}"#,
|
||||
);
|
||||
assert!(r1.contains(r#""status":"ok""#), "insert response: {}", r1);
|
||||
|
||||
let r2 = send(&mut c, r#"{"method":"scan","table":"users"}"#);
|
||||
assert!(r2.contains("Alice"), "scan response: {}", r2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_unknown_table_errors() {
|
||||
let pipe = unique_pipe_name();
|
||||
let (_srv, _data) = start_server(&pipe);
|
||||
let mut c = connect(&pipe).unwrap();
|
||||
|
||||
let r = send(&mut c, r#"{"method":"scan","table":"nope"}"#);
|
||||
assert!(r.contains(r#""status":"error""#), "got {}", r);
|
||||
assert!(r.contains("unknown table"), "got {}", r);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_tables_returns_created_tables() {
|
||||
let pipe = unique_pipe_name();
|
||||
let (_srv, _data) = start_server(&pipe);
|
||||
let mut c = connect(&pipe).unwrap();
|
||||
|
||||
send(&mut c, r#"{"method":"insert","table":"users","row":{"id":1}}"#);
|
||||
send(&mut c, r#"{"method":"insert","table":"orders","row":{"id":1}}"#);
|
||||
|
||||
let r = send(&mut c, r#"{"method":"list_tables"}"#);
|
||||
assert!(r.contains("users"), "got {}", r);
|
||||
assert!(r.contains("orders"), "got {}", r);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_all_replaces_rows() {
|
||||
let pipe = unique_pipe_name();
|
||||
let (_srv, _data) = start_server(&pipe);
|
||||
let mut c = connect(&pipe).unwrap();
|
||||
|
||||
send(&mut c, r#"{"method":"insert","table":"users","row":{"id":1,"name":"Alice"}}"#);
|
||||
send(&mut c, r#"{"method":"insert","table":"users","row":{"id":2,"name":"Bob"}}"#);
|
||||
|
||||
let r = send(
|
||||
&mut c,
|
||||
r#"{"method":"replace_all","table":"users","rows":[{"id":9,"name":"Zoe"}]}"#,
|
||||
);
|
||||
assert!(r.contains(r#""status":"ok""#), "replace_all response: {}", r);
|
||||
|
||||
let r = send(&mut c, r#"{"method":"scan","table":"users"}"#);
|
||||
assert!(r.contains("Zoe"), "got {}", r);
|
||||
assert!(!r.contains("Alice"), "old row should be gone: {}", r);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_with_key_then_lookup() {
|
||||
let pipe = unique_pipe_name();
|
||||
let (_srv, _data) = start_server(&pipe);
|
||||
let mut c = connect(&pipe).unwrap();
|
||||
|
||||
// 带索引键插三行
|
||||
for (id, name) in [(1, "Alice"), (2, "Bob"), (3, "Carol")] {
|
||||
let req = format!(
|
||||
r#"{{"method":"insert","table":"idx_users","row":{{"id":{},"name":"{}"}},"key":{}}}"#,
|
||||
id, name, id
|
||||
);
|
||||
let r = send(&mut c, &req);
|
||||
assert!(r.contains(r#""status":"ok""#), "insert: {}", r);
|
||||
}
|
||||
|
||||
// 命中
|
||||
let r = send(&mut c, r#"{"method":"lookup_by_index","table":"idx_users","key":2}"#);
|
||||
assert!(r.contains("Bob"), "lookup: {}", r);
|
||||
|
||||
// 未命中
|
||||
let r = send(&mut c, r#"{"method":"lookup_by_index","table":"idx_users","key":99}"#);
|
||||
assert!(r.contains(r#""rows":[]"#), "miss: {}", r);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_without_key_does_not_index() {
|
||||
let pipe = unique_pipe_name();
|
||||
let (_srv, _data) = start_server(&pipe);
|
||||
let mut c = connect(&pipe).unwrap();
|
||||
|
||||
send(&mut c, r#"{"method":"insert","table":"nokey","row":{"id":1}}"#);
|
||||
let r = send(&mut c, r#"{"method":"lookup_by_index","table":"nokey","key":1}"#);
|
||||
assert!(r.contains(r#""rows":[]"#), "got: {}", r);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lookup_survives_reopen() {
|
||||
let pipe = unique_pipe_name();
|
||||
let data = tempfile::tempdir().unwrap();
|
||||
|
||||
// 第一次:写 3 行带 key
|
||||
{
|
||||
let exe = env!("CARGO_BIN_EXE_server");
|
||||
let child = Command::new(exe)
|
||||
.env("CHUSQL_PIPE", &pipe)
|
||||
.env("CHUSQL_DATA_DIR", data.path())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let srv = ServerProc(child);
|
||||
for _ in 0..100 {
|
||||
if connect(&pipe).is_ok() { break; }
|
||||
thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
let mut c = connect(&pipe).unwrap();
|
||||
for (id, name) in [(1, "Alice"), (2, "Bob"), (3, "Carol")] {
|
||||
let req = format!(
|
||||
r#"{{"method":"insert","table":"persist","row":{{"id":{},"name":"{}"}},"key":{}}}"#,
|
||||
id, name, id
|
||||
);
|
||||
send(&mut c, &req);
|
||||
}
|
||||
drop(srv); // 杀 server
|
||||
}
|
||||
|
||||
// 等一下,让文件句柄真正释放
|
||||
thread::sleep(Duration::from_millis(200));
|
||||
|
||||
// 第二次:重开 server,从索引查到
|
||||
let (_srv, _data) = {
|
||||
let child = Command::new(env!("CARGO_BIN_EXE_server"))
|
||||
.env("CHUSQL_PIPE", &pipe)
|
||||
.env("CHUSQL_DATA_DIR", data.path())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let srv = ServerProc(child);
|
||||
for _ in 0..100 {
|
||||
if connect(&pipe).is_ok() { break; }
|
||||
thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
(srv, data)
|
||||
};
|
||||
|
||||
let mut c = connect(&pipe).unwrap();
|
||||
let r = send(&mut c, r#"{"method":"lookup_by_index","table":"persist","key":2}"#);
|
||||
assert!(r.contains("Bob"), "persist lookup: {}", r);
|
||||
}
|
||||
|
||||
/// 配置文件里的管道名和数据目录要真的生效(这里不设 CHUSQL_PIPE / CHUSQL_DATA_DIR)。
|
||||
#[test]
|
||||
fn config_file_is_used() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let pipe = unique_pipe_name();
|
||||
let data_dir = dir.path().join("data");
|
||||
let config_path = dir.path().join("chusql-storage.toml");
|
||||
|
||||
// TOML 里写正斜杠,省得转义 Windows 路径
|
||||
let text = format!(
|
||||
"[server]\npipe_name = \"{}\"\n[storage]\ndata_dir = \"{}\"\n[log]\nlevel = \"debug\"\n",
|
||||
pipe,
|
||||
data_dir.display().to_string().replace('\\', "/")
|
||||
);
|
||||
std::fs::write(&config_path, text).unwrap();
|
||||
|
||||
let child = Command::new(env!("CARGO_BIN_EXE_server"))
|
||||
.env("CHUSQL_CONFIG", &config_path)
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.expect("failed to spawn server");
|
||||
let _srv = ServerProc(child);
|
||||
for _ in 0..100 {
|
||||
if connect(&pipe).is_ok() {
|
||||
break;
|
||||
}
|
||||
thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
|
||||
let mut c = connect(&pipe).unwrap();
|
||||
assert_eq!(send(&mut c, r#"{"method":"ping"}"#), r#"{"status":"pong"}"#);
|
||||
let r = send(&mut c, r#"{"method":"insert","table":"cfg","row":{"id":1}}"#);
|
||||
assert!(r.contains(r#""status":"ok""#), "insert: {}", r);
|
||||
|
||||
// 数据真的落在配置文件指的那个目录里
|
||||
assert!(
|
||||
data_dir.join("cfg.db").exists(),
|
||||
"data dir from config file was not used"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
use chusql_storage::protocol::Row;
|
||||
use chusql_storage::wal::{Wal, WalOp};
|
||||
use serde_json::json;
|
||||
|
||||
fn row(pairs: &[(&str, serde_json::Value)]) -> Row {
|
||||
let mut m = Row::new();
|
||||
for (k, v) in pairs {
|
||||
m.insert((*k).to_string(), v.clone());
|
||||
}
|
||||
m
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_and_read_insert() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let wal = Wal::new(dir.path().join("wal.log"));
|
||||
|
||||
let op = WalOp::Insert {
|
||||
table: "users".into(),
|
||||
row: row(&[("id", json!(1)), ("name", json!("Alice"))]),
|
||||
key: Some(1),
|
||||
};
|
||||
wal.append(&op).unwrap();
|
||||
|
||||
let back = wal.read().unwrap().unwrap();
|
||||
assert_eq!(back, op);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_and_read_replace_all() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let wal = Wal::new(dir.path().join("wal.log"));
|
||||
|
||||
let op = WalOp::ReplaceAll {
|
||||
table: "users".into(),
|
||||
rows: vec![
|
||||
row(&[("id", json!(1)), ("name", json!("Alice"))]),
|
||||
row(&[("id", json!(2)), ("name", json!("Bob"))]),
|
||||
],
|
||||
};
|
||||
wal.append(&op).unwrap();
|
||||
|
||||
let back = wal.read().unwrap().unwrap();
|
||||
assert_eq!(back, op);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_file_returns_none() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let wal = Wal::new(dir.path().join("wal.log"));
|
||||
assert!(wal.read().unwrap().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_file_returns_none() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("wal.log");
|
||||
std::fs::write(&path, b"").unwrap();
|
||||
let wal = Wal::new(&path);
|
||||
assert!(wal.read().unwrap().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_truncates() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("wal.log");
|
||||
let wal = Wal::new(&path);
|
||||
|
||||
wal.append(&WalOp::Insert {
|
||||
table: "t".into(),
|
||||
row: row(&[("id", json!(1))]),
|
||||
key: None,
|
||||
})
|
||||
.unwrap();
|
||||
assert!(wal.read().unwrap().is_some());
|
||||
|
||||
wal.clear().unwrap();
|
||||
assert!(wal.read().unwrap().is_none());
|
||||
assert_eq!(std::fs::metadata(&path).unwrap().len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncated_tail_is_ignored() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("wal.log");
|
||||
let wal = Wal::new(&path);
|
||||
|
||||
wal.append(&WalOp::Insert {
|
||||
table: "users".into(),
|
||||
row: row(&[("id", json!(1)), ("name", json!("Alice"))]),
|
||||
key: Some(1),
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
// 砍掉最后 5 个字节,模拟"写一半断电"
|
||||
let full = std::fs::read(&path).unwrap();
|
||||
std::fs::write(&path, &full[..full.len() - 5]).unwrap();
|
||||
|
||||
// 读回来应该当作"没有这条"
|
||||
assert!(wal.read().unwrap().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overwrite_on_second_append_is_visible() {
|
||||
// 简化版一次只允许一条,append 两次会叠在文件里——
|
||||
// 但正常路径下 clear 先跑,所以实际不会叠加。
|
||||
// 这个测试只验证 read 能解析最前面那一条。
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let wal = Wal::new(dir.path().join("wal.log"));
|
||||
|
||||
wal.append(&WalOp::Insert {
|
||||
table: "t".into(),
|
||||
row: row(&[("id", json!(1))]),
|
||||
key: None,
|
||||
})
|
||||
.unwrap();
|
||||
let first = wal.read().unwrap().unwrap();
|
||||
assert!(matches!(first, WalOp::Insert { .. }));
|
||||
}
|
||||
Reference in new issue
Block a user