diff --git a/benchmark/chusql-engine/src/Main.hs b/benchmark/chusql-engine/src/Main.hs index 7222914..44f1fa9 100644 --- a/benchmark/chusql-engine/src/Main.hs +++ b/benchmark/chusql-engine/src/Main.hs @@ -4,13 +4,16 @@ import ChuSQL.Algebra.Eval (evalRelOp) import ChuSQL.Algebra.Optimize (optimize) import ChuSQL.Algebra.Planner (translate) import ChuSQL.Model -import ChuSQL.Syntax.Parser (parseQuery) +import ChuSQL.Syntax.Parser (parseStatement) import Control.Exception (evaluate) import Data.IORef (IORef, newIORef, readIORef) import System.CPUTime (getCPUTime) import System.Environment (getArgs) import Text.Printf (printf) +-- 性能基准:同一批语句,比较优化前后的耗时。 + +-- | 造一张 users 表 mkUsers :: Int -> Table mkUsers n = Table @@ -22,6 +25,7 @@ mkUsers n = ] } +-- | 造一张 orders 表 mkOrders :: Int -> Int -> Table mkOrders n m = Table @@ -33,37 +37,41 @@ mkOrders n m = ] } +-- | 结果校验和(防被优化掉) checksum :: Either String [Row] -> Int checksum (Left _) = -1 checksum (Right rows) = go 0 rows where + -- \| 累加行长 go acc [] = acc go acc (r : rs) = go (acc + length r) rs +-- | 结果行数 rowCount :: Either String [Row] -> Int rowCount (Left _) = -1 rowCount (Right rows) = length rows --- salt 是从 IORef 里读出来的:有了这个"IO 里来的"依赖,GHC 就没法把整段纯计算 --- 提到循环外只算一次 —— 否则批量计时会把 1 次的开销除以 k,测出 0.000 ms 这种假数据 +-- | 跑一次,返回校验和 runOnce :: IORef String -> Database -> String -> Bool -> IO Int runOnce saltRef db sql useOpt = do salt <- readIORef saltRef - let result = case parseQuery (sql ++ salt) >>= translate of + let result = case parseStatement (sql ++ salt) >>= translate of Left e -> Left e Right plan -> evalRelOp db (if useOpt then optimize db plan else plan) forced <- evaluate (checksum result) return forced +-- | 跑一次,返回行数 resultRows :: IORef String -> Database -> String -> Bool -> IO Int resultRows saltRef db sql useOpt = do salt <- readIORef saltRef - let result = case parseQuery (sql ++ salt) >>= translate of + let result = case parseStatement (sql ++ salt) >>= translate of Left e -> Left e Right plan -> evalRelOp db (if useOpt then optimize db plan else plan) _ <- evaluate (checksum result) return (rowCount result) +-- | 量一段 IO 的 CPU 时间 timed :: IO a -> IO Double timed act = do t0 <- getCPUTime @@ -71,13 +79,16 @@ timed act = do t1 <- getCPUTime return (fromIntegral (t1 - t0) / 1e12) +-- | 加倍批量次数再折算单次 benchOp :: Double -> IO Int -> IO (Double, Int) benchOp target act = loop 1 where + -- \| 批次翻倍直到超过目标 loop k = do t <- timed (mapM_ (const act) [1 .. k]) if t < target then loop (k * 2) else return (t / fromIntegral k, k) +-- | 打印一个用例的对比 report :: IORef String -> Database -> Double -> String -> IO () report saltRef db target sql = do nUn <- resultRows saltRef db sql False @@ -107,6 +118,7 @@ queries = warmupSql :: String warmupSql = "SELECT name FROM users" +-- | 基准入口 main :: IO () main = do args <- getArgs diff --git a/chusql-engine/app/Main.hs b/chusql-engine/app/Main.hs index 0afccac..1928ef2 100644 --- a/chusql-engine/app/Main.hs +++ b/chusql-engine/app/Main.hs @@ -8,6 +8,8 @@ import ChuSQL.Model import ChuSQL.Syntax.AST import ChuSQL.Syntax.Parser +-- 命令行演示:跑几条语句,打印结果与优化前后的计划。 + users :: Table users = Table @@ -35,17 +37,18 @@ orders = db :: Database db = [("users", users), ("orders", orders)] +-- | 演示入口 main :: IO () main = do putStrLn "==================== Engine =====================" let q1 = makeSelect ["name"] "users" (Just (Gt (Col "age") (LitInt 18))) - print (rowsOf (runQuery db q1)) + print (rowsOf (runStatement db q1)) let q2 = makeSelect ["*"] "users" Nothing - print (rowsOf (runQuery db q2)) + print (rowsOf (runStatement db q2)) let q3 = makeSelect ["name"] "nonexistent" Nothing - print (rowsOf (runQuery db q3)) + print (rowsOf (runStatement db q3)) let q4 = makeSelect @@ -57,17 +60,16 @@ main = do (Gt (Col "age") (LitInt 30)) ) ) - print (rowsOf (runQuery db q4)) + print (rowsOf (runStatement db q4)) - -- 解析 SQL 文本,再交给执行引擎 - let parsed = parseQuery "SELECT name FROM users WHERE age > 18 AND name = 'Alice'" - print (rowsOf (parsed >>= runQuery db)) + let parsed = parseStatement "SELECT name FROM users WHERE age > 18 AND name = 'Alice'" + print (rowsOf (parsed >>= runStatement db)) putStrLn "\n==================== Optimizer =====================" let planSql = "SELECT u.name, o.product FROM users u \ \JOIN orders o ON u.id = o.user_id WHERE u.age > 18" - case parseQuery planSql of + case parseStatement planSql of Left err -> putStrLn err Right query -> case translate query of Left err -> putStrLn err @@ -77,4 +79,4 @@ main = do putStrLn "--- plan after optimization ---" putStrLn (renderPlan (optimize db plan)) putStrLn "--- rows ---" - print (rowsOf (runQuery db query)) + print (rowsOf (runStatement db query)) diff --git a/chusql-engine/src/ChuSQL/Algebra/Eval.hs b/chusql-engine/src/ChuSQL/Algebra/Eval.hs index 7875913..714a309 100644 --- a/chusql-engine/src/ChuSQL/Algebra/Eval.hs +++ b/chusql-engine/src/ChuSQL/Algebra/Eval.hs @@ -8,29 +8,31 @@ import ChuSQL.Storage (MonadStorage (..)) import ChuSQL.Syntax.AST (Expr (..)) import Control.Monad (filterM) --- 执行:按关系算子树真正算出结果行(Scan 阶段会给列名加上别名前缀)。 +-- 执行:按算子树算出结果行(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)) --- 索引查找的约定:主键列名是 "id"(优化器只在这种情况下才把过滤改写成 Lookup)。 +-- | 主键匹配条件(id = k) 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 @@ -51,18 +53,18 @@ evalRelOp db (Limit n op) = do evalRelOp db (Join left right cond) = do lrows <- evalRelOp db left rrows <- evalRelOp db right - let cross = [l ++ r | l <- lrows, r <- rrows] -- 笛卡尔积 + let cross = [l ++ r | l <- lrows, r <- rrows] filterM (evalCondForRow cond) cross --- | Monadic 版本:Lookup 直接问存储要一行(走索引),其余算子照原样递归。 +-- | 单子求值:Lookup 问存储 evalRelOpM :: (MonadStorage m) => Database -> RelOp -> m (Either String [Row]) +evalRelOpM db (Scan a t) = pure (evalScan db a t) 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 diff --git a/chusql-engine/src/ChuSQL/Algebra/Expr.hs b/chusql-engine/src/ChuSQL/Algebra/Expr.hs index a47cbdf..c4533aa 100644 --- a/chusql-engine/src/ChuSQL/Algebra/Expr.hs +++ b/chusql-engine/src/ChuSQL/Algebra/Expr.hs @@ -4,9 +4,10 @@ import ChuSQL.Model import ChuSQL.Syntax.AST import Control.Monad (join) --- 表达式求值:在一行(列名 → 值)上算出单个值;另外提供"静态收集表达式里用到哪些列"的工具。 +-- 表达式求值:拿一行数据算出一个值。 --- 表达式求值 +-- * 求值 +-- | 在一行上求出表达式的值 evalExpr :: Expr -> Row -> Either String Value evalExpr (Col name) row = maybe (Left ("unknown column: " ++ name)) Right (lookup name row) @@ -19,7 +20,8 @@ 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 @@ -29,7 +31,7 @@ colsInExpr (And a b) = colsInExpr a ++ colsInExpr b colsInExpr (Or a b) = colsInExpr a ++ colsInExpr b colsInExpr _ = [] --- 对行计算条件语句 +-- | 判断条件真假 evalCondForRow :: Expr -> Row -> Either String Bool evalCondForRow e row = do v <- evalExpr e row @@ -37,7 +39,8 @@ evalCondForRow e row = do VBool b -> Right b _ -> Left "type error: WHERE condition must be a boolean" --- 固定模式:对两个子表达式求值,并返还给二元操作符 +-- * 内部 +-- | 把二元运算提升到 Either liftBinOp :: (Value -> Value -> Either String Value) -> Expr -> @@ -46,16 +49,16 @@ liftBinOp :: Either String Value liftBinOp f a b row = join (f <$> evalExpr a row <*> evalExpr b row) --- 整数比较 +-- | 整数二元运算 intOp :: (Int -> Int -> Bool) -> Value -> Value -> Either String Value intOp f (VInt x) (VInt y) = Right (VBool (f x y)) intOp _ _ _ = Left "type error: expected two integers" --- 布尔运算 +-- | 布尔二元运算 boolOp :: (Bool -> Bool -> Bool) -> Value -> Value -> Either String Value boolOp f (VBool x) (VBool y) = Right (VBool (f x y)) boolOp _ _ _ = Left "type error: expected two booleans" --- 相等比较 +-- | 相等比较 eqOp :: Value -> Value -> Either String Value eqOp x y = Right (VBool (x == y)) diff --git a/chusql-engine/src/ChuSQL/Algebra/Op.hs b/chusql-engine/src/ChuSQL/Algebra/Op.hs index bf4ab50..b5bd289 100644 --- a/chusql-engine/src/ChuSQL/Algebra/Op.hs +++ b/chusql-engine/src/ChuSQL/Algebra/Op.hs @@ -2,9 +2,9 @@ module ChuSQL.Algebra.Op (RelOp (..), renderPlan) where import ChuSQL.Syntax.AST (Expr, SortDir) --- 关系代数算子:执行计划用 RelOp 表示;另外提供一个把计划打印成缩进文本的辅助函数。 +-- 关系代数算子,以及把算子树渲染成文本。 --- 关系代数算子 +-- | 算子树的一个节点 data RelOp = Scan (Maybe String) String | Lookup String Int @@ -15,13 +15,13 @@ 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 + Lookup t k -> pad ++ "Lookup " ++ show t ++ " " ++ show k Filter e x -> pad ++ "Filter " ++ show e ++ "\n" ++ go (ind + 1) x Project c x -> pad ++ "Project " ++ show c ++ "\n" ++ go (ind + 1) x Sort s x -> pad ++ "Sort " ++ show s ++ "\n" ++ go (ind + 1) x diff --git a/chusql-engine/src/ChuSQL/Algebra/Optimize.hs b/chusql-engine/src/ChuSQL/Algebra/Optimize.hs index 76d4516..9033b36 100644 --- a/chusql-engine/src/ChuSQL/Algebra/Optimize.hs +++ b/chusql-engine/src/ChuSQL/Algebra/Optimize.hs @@ -5,42 +5,41 @@ import ChuSQL.Algebra.Op (RelOp (..)) import ChuSQL.Model import ChuSQL.Syntax.AST (Expr (..)) --- 查询优化:谓词下推 + 投影下推 + 常量折叠,反复应用规则直到算子树形状不再变化。 +-- 查询优化:谓词下推 + 投影下推 + 常量折叠,跑到不动点。 -- * 谓词下推 - --- | 一个算子会产出哪些列;返回 ["*"] 表示"全部列,不用挑" +-- | 算子会产出哪些列 relOpCols :: Database -> RelOp -> [String] -relOpCols db (Filter _ x) = relOpCols db x -relOpCols db (Sort _ x) = relOpCols db x -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 -> [] Just t -> map (prefix ++) (colNames t) where prefix = maybe "" (++ ".") mAlias +relOpCols db (Lookup tbl _) = relOpCols db (Scan Nothing tbl) +relOpCols db (Filter _ x) = relOpCols db x +relOpCols _ (Project ["*"] _) = ["*"] +relOpCols _ (Project cols _) = cols +relOpCols db (Sort _ x) = relOpCols db x +relOpCols db (Limit _ x) = relOpCols db x +relOpCols db (Join l r _) = relOpCols db l ++ relOpCols db r --- | need 里的列是不是都能在 have 里找到;have 为 ["*"] 时一律为真 +-- | need 是否都在 have 里 isSubsetOf :: [String] -> [String] -> Bool isSubsetOf _ ["*"] = True isSubsetOf need have = all (`elem` have) need --- | 把一个用 AND 串起来的条件拆成清单:a AND b AND c 变成 [a, b, c] +-- | 把 AND 链拆成清单 splitConj :: Expr -> [Expr] splitConj (And a b) = splitConj a ++ splitConj b splitConj e = [e] --- | 反过来:把条件清单用 AND 重新串成一个;空清单表示"没有条件",返回 Nothing +-- | 把条件清单用 AND 串起来 combineConj :: [Expr] -> Maybe Expr combineConj [] = Nothing combineConj (e : es) = Just (foldl And e es) --- | 把条件按"只用到哪一边的列"分成两堆:能下推过去的 / 必须留在原地的 +-- | 条件按列归属分两边 partitionPred :: [String] -> [Expr] -> ([Expr], [Expr]) partitionPred have = foldr pick ([], []) where @@ -48,12 +47,12 @@ partitionPred have = foldr pick ([], []) | colsInExpr e `isSubsetOf` have = (e : yes, no) | otherwise = (yes, e : no) --- | 把一组条件依次套到算子上,一个条件一层 Filter +-- | 依次套上若干 Filter applyFilters :: [Expr] -> RelOp -> RelOp applyFilters [] op = op applyFilters (e : es) op = applyFilters es (Filter e op) --- | Filter 压在 Join 上时的下推规则:条件按列归属拆到左右两侧,跨两边的才留在 Join 上方 +-- | Filter 压在 Join 上时下推 pushJoin :: Database -> Expr -> RelOp -> RelOp -> Expr -> RelOp pushJoin db p l r c = let preds = splitConj p @@ -68,26 +67,26 @@ pushJoin db p l r c = Nothing -> joined Just p' -> Filter p' joined --- | Filter 压在 Filter / Sort 上时的下推规则:合并上下两层 Filter,或把 Filter 挪到 Sort 下面 +-- | 合并两层 Filter 或挪到 Sort 下 pushOne :: Database -> RelOp -> RelOp pushOne _ (Filter p (Filter q x)) = Filter (And p q) x pushOne _ (Filter p (Sort spec x)) = Sort spec (Filter p x) 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 @@ -107,9 +106,9 @@ pushProject db need (Join l r c) pushProject db need leaf@(Scan _ _) = pushProjectLeaf db need leaf pushProject db need leaf@(Lookup _ _) = pushProjectLeaf db need leaf --- * 常量折叠 --- | 折一个表达式:不含列就直接求值替换成字面量,含列或求值失败则原样返回 +-- * 常量折叠 +-- | 折一个表达式节点 foldNode :: Expr -> Expr foldNode e | null (colsInExpr e) = case evalExpr e [] of @@ -119,7 +118,7 @@ foldNode e Left _ -> e | otherwise = e --- | 自底向上折叠整个表达式:先把子表达式折干净,再折自己 +-- | 自底向上折整棵表达式 foldConstants :: Expr -> Expr foldConstants (Gt a b) = foldNode (Gt (foldConstants a) (foldConstants b)) foldConstants (Lt a b) = foldNode (Lt (foldConstants a) (foldConstants b)) @@ -128,9 +127,9 @@ foldConstants (And a b) = foldNode (And (foldConstants a) (foldConstants b)) foldConstants (Or a b) = foldNode (Or (foldConstants a) (foldConstants b)) foldConstants e = e --- * 执行优化 --- | 单节点重写:把 Filter 分派给上面几条规则,另外负责恒真条件删除与恒等投影消除 +-- * 执行优化 +-- | 单节点重写 rewriteNode :: Database -> RelOp -> RelOp rewriteNode _ (Filter (Eq (Col k) (LitInt v)) (Scan _ t)) | k == "id" = Lookup t v @@ -141,7 +140,7 @@ rewriteNode db (Filter p x) = case foldConstants p of rewriteNode _ (Project ["*"] x) = x rewriteNode db op = pushOne db op --- | 自底向上遍历整棵算子树,每个节点套一次 rewriteNode +-- | 自底向上遍历一次 optimizeRelOp :: Database -> RelOp -> RelOp optimizeRelOp db = rewriteNode db . descend where @@ -152,12 +151,12 @@ optimizeRelOp db = rewriteNode db . descend descend (Join l r c) = Join (optimizeRelOp db l) (optimizeRelOp db r) c descend x = x --- | 谓词下推 + 常量折叠:反复应用规则,直到算子树的形状不再变化(到不动点为止) +-- | 反复跑到不动点 optimizePredicates :: Database -> RelOp -> RelOp optimizePredicates db op = let op' = optimizeRelOp db op in if op' == op then op else optimizePredicates db op' --- | 优化器总入口:先把谓词下推与常量折叠跑到不动点,再做一次投影下推 +-- | 优化总入口 optimize :: Database -> RelOp -> RelOp optimize db op = pushProject db ["*"] (optimizePredicates db op) diff --git a/chusql-engine/src/ChuSQL/Algebra/Planner.hs b/chusql-engine/src/ChuSQL/Algebra/Planner.hs index 909cd4b..404bab0 100644 --- a/chusql-engine/src/ChuSQL/Algebra/Planner.hs +++ b/chusql-engine/src/ChuSQL/Algebra/Planner.hs @@ -3,16 +3,16 @@ module ChuSQL.Algebra.Planner (translate) where import ChuSQL.Algebra.Op import ChuSQL.Syntax.AST --- 计划翻译:把 AST 里的 SELECT 翻译成关系代数算子树(只翻译,不做合法性检查)。 +-- 计划生成:把语句翻译成算子树。 --- 把 FROM 从句转成 Scan / Join 算子。 +-- | 把 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 :: Statement -> Either String RelOp translate q = case q of Select { selectCols = cols diff --git a/chusql-engine/src/ChuSQL/Algebra/Sort.hs b/chusql-engine/src/ChuSQL/Algebra/Sort.hs index 1d9c471..0dbb3a0 100644 --- a/chusql-engine/src/ChuSQL/Algebra/Sort.hs +++ b/chusql-engine/src/ChuSQL/Algebra/Sort.hs @@ -4,22 +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 = @@ -33,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 diff --git a/chusql-engine/src/ChuSQL/Engine.hs b/chusql-engine/src/ChuSQL/Engine.hs index 8215b18..03caff6 100644 --- a/chusql-engine/src/ChuSQL/Engine.hs +++ b/chusql-engine/src/ChuSQL/Engine.hs @@ -1,4 +1,4 @@ -module ChuSQL.Engine (runQuery, runQueryM, rowsOf) where +module ChuSQL.Engine (runStatement, runStatementM, rowsOf) where import ChuSQL.Algebra.Eval (evalRelOpM) import ChuSQL.Algebra.Expr (evalCondForRow, evalExpr) @@ -11,37 +11,39 @@ 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 - (result, db') <- runMemoryStorage (runQueryM q) db +-- 引擎入口:语义检查 + 分发执行,以及内存实现与泛型版本。 + +-- * 对外入口 +-- | 跑一条语句(内存实现) +runStatement :: Database -> Statement -> Either String (Database, [Row]) +runStatement db q = do + (result, db') <- runMemoryStorage (runStatementM q) db rows <- result Right (db', rows) --- | 只要结果行(丢掉更新后的数据库),给演示和测试用。 +-- | 只要结果行 rowsOf :: Either String (Database, [Row]) -> Either String [Row] rowsOf = fmap snd --- * 泛型入口:走 MonadStorage,未来 IPCStorage 复用同一份代码 --- | 语义检查 + 分发到具体语句。 -runQueryM :: (MonadStorage m) => Query -> m (Either String [Row]) -runQueryM q = do +-- | 泛型入口:先检查再执行 +runStatementM :: (MonadStorage m) => Statement -> m (Either String [Row]) +runStatementM q = do db <- snapshot case check db q of Left err -> pure (Left err) - Right _ -> runQueryUncheckedM q + Right _ -> runStatementUncheckedM q --- | SELECT:拿快照,跑 translate → optimize → evalRelOp。 -runQueryUncheckedM :: (MonadStorage m) => Query -> m (Either String [Row]) -runQueryUncheckedM q@Select{} = do +-- * 分发执行 +-- | 按语句类型分发(已检查过) +runStatementUncheckedM :: (MonadStorage m) => Statement -> m (Either String [Row]) +runStatementUncheckedM q@Select{} = do db <- snapshot case translate q of Left e -> pure (Left e) Right relOp -> evalRelOpM db (optimize db relOp) --- \| INSERT:求值后追加一行。 -runQueryUncheckedM (Insert tbl cols vals) = +runStatementUncheckedM (Insert tbl cols vals) = case mapM (\e -> evalExpr e []) vals of Left err -> pure (Left err) Right values @@ -51,8 +53,7 @@ runQueryUncheckedM (Insert tbl cols vals) = result <- insert tbl (zip cols values) pure (result >> Right []) --- \| DELETE:扫表、筛掉命中行、整体写回。 -runQueryUncheckedM (Delete tbl mWhere) = do +runStatementUncheckedM (Delete tbl mWhere) = do rowsResult <- scan tbl case rowsResult of Left err -> pure (Left err) @@ -66,12 +67,11 @@ runQueryUncheckedM (Delete tbl mWhere) = do result <- replaceAll tbl keptRows pure (result >> Right []) where - -- \| 保留 = 条件不成立。 + -- \| 保留 = 条件不成立 shouldKeep :: Expr -> Row -> Either String Bool shouldKeep e row = not <$> evalCondForRow e row --- \| UPDATE:扫表、逐行改、整体写回。 -runQueryUncheckedM (Update tbl assigns mWhere) = do +runStatementUncheckedM (Update tbl assigns mWhere) = do rowsResult <- scan tbl case rowsResult of Left err -> pure (Left err) @@ -83,7 +83,7 @@ runQueryUncheckedM (Update tbl assigns mWhere) = 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 @@ -91,7 +91,15 @@ runQueryUncheckedM (Update tbl assigns mWhere) = do Just e -> evalCondForRow e row if keep then applyUpdates asgns row else Right row --- | 按 assignments 依次求值并覆盖对应列。 +runStatementUncheckedM (CreateTable name cols) = do + result <- createTable name cols + pure (result >> Right []) +runStatementUncheckedM (DropTable name) = do + result <- dropTable name + pure (result >> Right []) + +-- * 更新辅助 +-- | 依次求值并覆盖列 applyUpdates :: [(String, Expr)] -> Row -> Either String Row applyUpdates [] row = Right row applyUpdates ((col, e) : rest) row = do diff --git a/chusql-engine/src/ChuSQL/Model.hs b/chusql-engine/src/ChuSQL/Model.hs index 0d0be17..6f6de07 100644 --- a/chusql-engine/src/ChuSQL/Model.hs +++ b/chusql-engine/src/ChuSQL/Model.hs @@ -1,25 +1,26 @@ module ChuSQL.Model (Column (..), Value (..), Row, Table (..), Database, colNames, colType, lookupTable, allColumns) where --- 数据模型:列类型、值、行、表、数据库,外加"按名查表 / 查列类型"这类通用小工具。 +-- 数据模型:列类型、值、行、表、数据库,外加几个查表小工具。 --- 列的类型 +-- * 类型 +-- | 列的类型 data Column = TInt | TStr | TBool deriving (Show, Eq) --- 列的值:整数、字符串、布尔 +-- | 列的值 data Value = VInt Int | VStr String | VBool Bool deriving (Show, Eq) --- 一行:列名 → 值 +-- | 一行:列名 -> 值 type Row = [(String, Value)] --- 一张表 +-- | 一张表 data Table = Table { tableName :: String , tableCols :: [(String, Column)] @@ -27,21 +28,22 @@ data Table = Table } deriving (Show) --- 数据库:多张表 +-- | 数据库:多张表 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 = "*" diff --git a/chusql-engine/src/ChuSQL/Semantic.hs b/chusql-engine/src/ChuSQL/Semantic.hs index d5c5d18..e7cd7fd 100644 --- a/chusql-engine/src/ChuSQL/Semantic.hs +++ b/chusql-engine/src/ChuSQL/Semantic.hs @@ -3,19 +3,18 @@ module ChuSQL.Semantic (check) where import ChuSQL.Algebra.Expr (colsInExpr) import ChuSQL.Model import ChuSQL.Syntax.AST -import Data.List (intercalate) +import Data.List (intercalate, nub) --- 语义检查:执行前的关卡 —— 表和列存不存在、表达式类型对不对、写入的值类型是否匹配。 +-- 语义检查:表和列在不在、类型对不对,全部在执行前查。 --- * 表与列名解析 - --- | 给一批列加上"别名."前缀 +-- * 环境 +-- | 给列名加上别名前缀 prefixColumns :: Maybe String -> [(String, Column)] -> [(String, Column)] prefixColumns mAlias cols = [(prefix ++ c, ty) | (c, ty) <- cols] where prefix = maybe "" (++ ".") mAlias --- | 走一遍 FROM:算出可用列(带别名前缀),顺手把每个 ON 条件也查了 +-- | 收集 FROM 能提供的列 checkFrom :: Database -> FromClause -> Either String [(String, Column)] checkFrom db (FromTable mAlias tbl) = do t <- lookupTable db tbl @@ -27,9 +26,9 @@ checkFrom db (FromJoin left mAlias tbl cond) = do checkBool "ON" env cond Right env --- * 列检查与报错 --- | 统一拼一条带"可用列清单"的报错 +-- * 检查 +-- | 拼“列不存在”的报错 missingColumn :: String -> String -> [(String, Column)] -> String missingColumn place c env = "unknown column in " ++ place ++ ": " ++ c ++ hint @@ -38,16 +37,15 @@ missingColumn place c env = | null env = "" | otherwise = " (available: " ++ intercalate ", " (map fst env) ++ ")" --- | 请求的列必须都在可用清单里;哨兵 allColumns 表示"全部列",放行 +-- | 检查列是否都在环境里 checkColumns :: String -> [(String, Column)] -> [String] -> Either String () checkColumns place env requested = case [c | c <- requested, c /= allColumns, c `notElem` map fst env] of [] -> Right () (c : _) -> Left (missingColumn place c env) --- * 表达式类型推导 --- | 推出一个表达式的结果类型;列不存在、或类型对不上,都报错 +-- | 推导表达式的类型 inferExpr :: String -> [(String, Column)] -> Expr -> Either String Column inferExpr place env (Col c) = maybe (Left (missingColumn place c env)) Right (lookup c env) inferExpr _ _ (LitInt _) = Right TInt @@ -64,7 +62,7 @@ inferExpr place env (Eq a b) = do then Right TBool else Left (place ++ ": both sides of = must have the same type, got " ++ show ta ++ " and " ++ show tb) --- | 二元运算的通用检查:两边都必须是同一种期望类型,结果都是布尔 +-- | 检查二元运算两边的类型 operands :: String -> [(String, Column)] -> Column -> Expr -> Expr -> Either String Column operands place env want a b = do ta <- inferExpr place env a @@ -73,7 +71,7 @@ operands place env want a b = do then Right TBool else Left (place ++ ": operator needs " ++ show want ++ " on both sides, got " ++ show ta ++ " and " ++ show tb) --- | 这个表达式必须能算成一个条件(布尔) +-- | 要求条件能算出布尔 checkBool :: String -> [(String, Column)] -> Expr -> Either String () checkBool place env e = do checkColumns place env (colsInExpr e) @@ -82,9 +80,9 @@ checkBool place env e = do then Right () else Left (place ++ ": condition must be a boolean, got " ++ show t) --- * 写入语句 --- | 一条赋值的通用检查:目标列必须存在,右边表达式的类型要对得上 +-- * 写入语句 +-- | 检查赋值列和类型 checkTyped :: String -> [(String, Column)] -> Table -> (String, Expr) -> Either String () checkTyped place env t (c, e) = do want <- maybe (Left (missingColumn place c (tableCols t))) Right (colType t c) @@ -93,18 +91,18 @@ checkTyped place env t (c, e) = do then Right () else Left (place ++ ": column " ++ c ++ " needs " ++ show want ++ ", got " ++ show got) --- | INSERT 的一个值:环境是空的 —— 值必须是字面量,写列名会被拦下 +-- | 检查插入值的类型 checkValue :: Table -> (String, Expr) -> Either String () checkValue = checkTyped "INSERT" [] --- | UPDATE 的一个赋值:环境是整张表 —— 右边可以引用本行的列 +-- | 检查 UPDATE 的赋值 checkAssign :: Table -> (String, Expr) -> Either String () checkAssign t = checkTyped "UPDATE" (tableCols t) t --- * 总入口 --- | 语义检查总入口:合法返回 Right (),不合法返回 Left(带人话解释) -check :: Database -> Query -> Either String () +-- * 入口 +-- | 按语句类型分派检查 +check :: Database -> Statement -> Either String () check db q = case q of Select { selectCols = cols @@ -128,3 +126,15 @@ check db q = case q of checkColumns "UPDATE" (tableCols t) (map fst assigns) mapM_ (checkAssign t) assigns mapM_ (checkBool "WHERE" (tableCols t)) mWhere + CreateTable name cols -> do + if null name + then Left "CREATE TABLE: empty table name" + else do + let names = map fst cols + if length names /= length (nub names) + then Left ("CREATE TABLE: duplicate column names in " ++ name) + else Right () + DropTable name -> + if null name + then Left "DROP TABLE: empty table name" + else Right () diff --git a/chusql-engine/src/ChuSQL/Storage.hs b/chusql-engine/src/ChuSQL/Storage.hs index 87435bd..8ee9a26 100644 --- a/chusql-engine/src/ChuSQL/Storage.hs +++ b/chusql-engine/src/ChuSQL/Storage.hs @@ -1,20 +1,30 @@ -module ChuSQL.Storage (MonadStorage (..)) where +module ChuSQL.Storage ( + MonadStorage (..), +) where -import ChuSQL.Model (Database, Row) +import ChuSQL.Model (Column, Database, Row) --- 存储层抽象:上层只认 domain 类型,不关心底层是内存还是 IPC。 +-- 存储层抽象:上层只认 domain 类型,不关心底层实现。 + +-- | 存储接口:读写表、按主键取行、取快照 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。 + -- \| 建一张空表 + createTable :: String -> [(String, Column)] -> m (Either String ()) + + -- \| 删除表 + dropTable :: String -> m (Either String ()) + + -- \| 取全库快照 snapshot :: m Database diff --git a/chusql-engine/src/ChuSQL/Storage/IPC.hs b/chusql-engine/src/ChuSQL/Storage/IPC.hs index 3a89c41..facf87c 100644 --- a/chusql-engine/src/ChuSQL/Storage/IPC.hs +++ b/chusql-engine/src/ChuSQL/Storage/IPC.hs @@ -11,11 +11,13 @@ module ChuSQL.Storage.IPC ( doLookupByKey, doInsertKeyed, doDescribeTable, + doCreateTable, + doDropTable, ) where import ChuSQL.Model import ChuSQL.Storage -import Control.Exception (bracket) +import Control.Exception (IOException, bracket, try) import Data.Aeson (FromJSON (..), ToJSON (..), eitherDecodeStrict, encode, object, withObject, (.:), (.=)) import Data.Aeson qualified as A import Data.Aeson.Key qualified as K @@ -29,39 +31,41 @@ import Data.Text qualified as T import System.Environment (lookupEnv) import System.IO --- IPC 存储实现:每个 MonadStorage 方法翻译成管道上的一条 JSON 请求。 +-- IPC 存储实现:每个存储方法翻成管道上的一条 JSON 请求。 --- | IPC 实现:通过命名管道连 Rust 存储进程。 +-- * 存储实现 +-- | 命名管道上的存储 newtype IPCStorage a = IPCStorage { runIPCStorage :: IO a } --- | Functor:把 fmap 转发给底下的 IO。 +-- | Functor:转发给 IO instance Functor IPCStorage where fmap f (IPCStorage m) = IPCStorage (fmap f m) --- | Applicative:把 pure / <*> 转发给底下的 IO。 +-- | Applicative:转发给 IO instance Applicative IPCStorage where pure = IPCStorage . pure IPCStorage mf <*> IPCStorage ma = IPCStorage (mf <*> ma) --- | Monad:顺序执行,转发给底下的 IO。 +-- | Monad:顺序执行 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 @@ -69,39 +73,46 @@ withPipe = bracket openPipe hClose 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 +sendRequest req = do + result <- try (withPipe roundTrip) :: IO (Either IOException Response) + pure $ case result of + Left e -> RespError ("pipe error: " ++ show e) + Right r -> r + where + roundTrip 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。 +-- * 编解码 +-- | 一列的 schema data SchemaColumn = SchemaColumn { scName :: String , scType :: String } deriving (Show, Eq) --- | SchemaColumn 编码;Haskell 侧只发 DescribeTable,编码用不上,暂存实现。 +-- | SchemaColumn 编码 instance ToJSON SchemaColumn where toJSON (SchemaColumn n t) = object ["name" .= n, "ty" .= t] --- | SchemaColumn 解码:从 {"name": "...", "ty": "int"|"str"|"bool"} 读回。 +-- | SchemaColumn 解码 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 @@ -110,8 +121,10 @@ data Request | ReqListTables | ReqLookupByKey String Int | ReqDescribeTable String + | ReqCreateTable String [SchemaColumn] + | ReqDropTable String --- | 请求编码:method 字段 + 各自参数。 +-- | 请求编码 instance ToJSON Request where toJSON ReqPing = object ["method" .= ("ping" :: T.Text)] toJSON (ReqScan t) = object ["method" .= ("scan" :: T.Text), "table" .= t] @@ -142,17 +155,26 @@ instance ToJSON Request where [ "method" .= ("describe_table" :: T.Text) , "table" .= t ] + toJSON (ReqCreateTable t cols) = + object + [ "method" .= ("create_table" :: T.Text) + , "table" .= t + , "columns" .= cols + ] + toJSON (ReqDropTable t) = + object ["method" .= ("drop_table" :: T.Text), "table" .= t] --- | 响应;对应 Rust 侧带 status tag 的 enum。 + +-- | 响应类型 data Response = RespPong | RespRows [Row] | RespTables [String] - | RespSchema [SchemaColumn] + | RespSchema [SchemaColumn] Int | RespOk | RespError String --- | 响应解码:按 status 字段分派。 +-- | 响应解码 instance FromJSON Response where parseJSON = withObject "Response" $ \o -> do status <- o .: "status" @@ -161,17 +183,20 @@ instance FromJSON Response where "ok" -> pure RespOk "rows" -> RespRows <$> (o .: "rows" >>= mapM rowFromJSON) "tables" -> RespTables <$> o .: "tables" - "schema" -> RespSchema <$> o .: "columns" "error" -> RespError <$> o .: "message" + "schema" -> do + cols <- o .: "columns" + cnt <- o .: "row_count" + pure (RespSchema cols cnt) other -> fail ("unknown status: " ++ T.unpack other) --- | Value 编码成 JSON 的 number / string / bool。 +-- | 值编码成 JSON 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 只接受整数。 +-- | JSON 解回值 valueFromJSON :: A.Value -> Parser Value valueFromJSON (A.Number n) = case floatingOrInteger n :: Either Double Integer of @@ -181,24 +206,24 @@ valueFromJSON (A.String s) = pure (VStr (T.unpack s)) valueFromJSON (A.Bool b) = pure (VBool b) valueFromJSON _ = fail "unsupported value type" --- | 一行编成 JSON object。 +-- | 一行编码成 JSON rowToJSON :: Row -> A.Value rowToJSON r = A.Object (KM.fromList [(K.fromString k, valueToJSON v) | (k, v) <- r]) --- | JSON object 解成一行。 +-- | JSON 解回一行 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。 +-- * 内部封装 +-- | 发 Scan doScan :: String -> IO (Either String [Row]) doScan t = do resp <- sendRequest (ReqScan t) @@ -207,16 +232,22 @@ doScan t = do RespError e -> Left e _ -> Left "unexpected response to scan" --- | 发 Insert(不带索引键)。 +-- | 发 Insert(带 id 键) doInsert :: String -> Row -> IO (Either String ()) doInsert t r = do - resp <- sendRequest (ReqInsert t r Nothing) + resp <- sendRequest (ReqInsert t r (rowKey r)) pure $ case resp of RespOk -> Right () RespError e -> Left e _ -> Left "unexpected response to insert" --- | 发 Insert(带索引键)。 +-- | 取行里的 id 作索引键 +rowKey :: Row -> Maybe Int +rowKey r = case lookup "id" r of + Just (VInt k) -> Just k + _ -> Nothing + +-- | 发 Insert(指定键) doInsertKeyed :: String -> Row -> Int -> IO (Either String ()) doInsertKeyed t r k = do resp <- sendRequest (ReqInsert t r (Just k)) @@ -225,7 +256,7 @@ doInsertKeyed t r k = do RespError e -> Left e _ -> Left "unexpected response to insert" --- | 发 ReplaceAll。 +-- | 发 ReplaceAll doReplaceAll :: String -> [Row] -> IO (Either String ()) doReplaceAll t rs = do resp <- sendRequest (ReqReplaceAll t rs) @@ -234,7 +265,7 @@ doReplaceAll t rs = do RespError e -> Left e _ -> Left "unexpected response to replace_all" --- | 发 ListTables。 +-- | 发 ListTables doListTables :: IO (Either String [String]) doListTables = do resp <- sendRequest ReqListTables @@ -243,7 +274,7 @@ doListTables = do RespError e -> Left e _ -> Left "unexpected response to list_tables" --- | 发 LookupByIndex。 +-- | 发 LookupByIndex doLookupByKey :: String -> Int -> IO (Either String (Maybe Row)) doLookupByKey t k = do resp <- sendRequest (ReqLookupByKey t k) @@ -253,45 +284,63 @@ doLookupByKey t k = do RespError e -> Left e _ -> Left "unexpected response to lookup_by_index" --- | 发 DescribeTable。 -doDescribeTable :: String -> IO (Either String [SchemaColumn]) +-- | 发 DescribeTable +doDescribeTable :: String -> IO (Either String ([SchemaColumn], Int)) doDescribeTable t = do resp <- sendRequest (ReqDescribeTable t) pure $ case resp of - RespSchema cols -> Right cols + RespSchema cols n -> Right (cols, n) RespError e -> Left e _ -> Left "unexpected response to describe_table" --- | 把 Rust 的 schema 列转成 Model 的 Column;未知类型降级为 TStr。 +-- | 发 CreateTable +doCreateTable :: String -> [SchemaColumn] -> IO (Either String ()) +doCreateTable t cols = do + resp <- sendRequest (ReqCreateTable t cols) + pure $ case resp of + RespOk -> Right () + RespError e -> Left e + _ -> Left "unexpected response to create_table" + +-- | 发 DropTable +doDropTable :: String -> IO (Either String ()) +doDropTable t = do + resp <- sendRequest (ReqDropTable t) + pure $ case resp of + RespOk -> Right () + RespError e -> Left e + _ -> Left "unexpected response to drop_table" + +-- * 表结构 +-- | 线上 schema 转本地列 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 拿不到时退回扫行推断。 +-- | 扫一张表拼成 Table loadTable :: String -> IO Table loadTable t = do schemaR <- doDescribeTable t case schemaR of - Right cols -> do + Right (cols, _n) -> do rowsR <- doScan t case rowsR of Right rows -> pure (Table t (schemaToColumns cols) rows) @@ -302,30 +351,41 @@ loadTable t = do Right rows -> pure (Table t (inferColumns rows) rows) Left _ -> pure (Table t [] []) --- * MonadStorage 实例 --- | IPC 实现:所有方法都走管道。 +-- * MonadStorage 实例 +-- | 存储方法全走管道 instance MonadStorage IPCStorage where - -- \| 发 Scan。 + -- \| 发 Scan scan t = IPCStorage (doScan t) - -- \| 发 Insert,不带索引键。 + -- \| 发 Insert insert t r = IPCStorage (doInsert t r) - -- \| 发 ReplaceAll。 + -- \| 发 ReplaceAll replaceAll t rs = IPCStorage (doReplaceAll t rs) - -- \| 发 LookupByIndex。 + -- \| 发 LookupByIndex lookupByKey t k = IPCStorage (doLookupByKey t k) - -- \| ListTables + 逐表 (describe, scan),拼成 Database。 + -- \| 发 CreateTable + createTable name cols = IPCStorage (doCreateTable name (map toWire cols)) + where + -- \| 本地列类型转线上字符串 + toWire (n, TInt) = SchemaColumn n "int" + toWire (n, TStr) = SchemaColumn n "str" + toWire (n, TBool) = SchemaColumn n "bool" + + -- \| 发 DropTable + dropTable name = IPCStorage (doDropTable name) + + -- \| 列表 + 逐表扫描拼库 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) diff --git a/chusql-engine/src/ChuSQL/Storage/Memory.hs b/chusql-engine/src/ChuSQL/Storage/Memory.hs index 03e5332..0e9b46f 100644 --- a/chusql-engine/src/ChuSQL/Storage/Memory.hs +++ b/chusql-engine/src/ChuSQL/Storage/Memory.hs @@ -3,16 +3,19 @@ module ChuSQL.Storage.Memory (MemoryStorage (runMemoryStorage)) where import ChuSQL.Model import ChuSQL.Storage --- | 内存实现:本质是 Database 上的 State,错误通道是 Either String。 +-- 内存实现:状态是 Database,错误通道是 Either String。 + +-- * 实例 +-- | 内存存储:Database 上的状态 newtype MemoryStorage a = MemoryStorage {runMemoryStorage :: Database -> Either String (a, Database)} --- | Functor:把 fmap 转发给底下的 State。 +-- | Functor:转发给底层状态 instance Functor MemoryStorage where fmap f (MemoryStorage m) = MemoryStorage $ \db -> do (a, db') <- m db Right (f a, db') --- | Applicative:转发 pure / <*>。 +-- | Applicative:转发 pure 和 <*> instance Applicative MemoryStorage where pure x = MemoryStorage $ \db -> Right (x, db) MemoryStorage mf <*> MemoryStorage ma = MemoryStorage $ \db -> do @@ -20,47 +23,60 @@ instance Applicative MemoryStorage where (a, db2) <- ma db1 Right (f a, db2) --- | Monad:顺序执行,把动作转发给底下的 State。 +-- | Monad:顺序执行 instance Monad MemoryStorage where MemoryStorage m >>= k = MemoryStorage $ \db -> do (a, db') <- m db runMemoryStorage (k a) db' --- | MonadStorage 实例:内存里直接改 Database。 +-- | MonadStorage:直接改内存库 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) - -- \| 原样返回当前数据库。 + -- \| 建空表;已存在报错 + createTable name cols = MemoryStorage $ \db -> + if any ((== name) . fst) db + then Right (Left ("table already exists: " ++ name), db) + else Right (Right (), db ++ [(name, Table name cols [])]) + + -- \| 删表;不存在报错 + dropTable name = MemoryStorage $ \db -> + if any ((== name) . fst) db + then Right (Right (), filter ((/= name) . fst) db) + else Right (Left ("unknown table: " ++ name), db) + + -- \| 原样返回当前库 snapshot = MemoryStorage $ \db -> Right (db, db) --- | 按 id 列匹配一个整数。 +-- * 工具 +-- | 按 id 列找一行 findRow :: Table -> Int -> Maybe Row findRow tbl k = case filter matches (tableRows tbl) of (r : _) -> Just r [] -> Nothing where - -- \| 这行的 id 列是否等于 k。 + -- \| 这行的 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)) diff --git a/chusql-engine/src/ChuSQL/Syntax/AST.hs b/chusql-engine/src/ChuSQL/Syntax/AST.hs index a4f57e6..7dff886 100644 --- a/chusql-engine/src/ChuSQL/Syntax/AST.hs +++ b/chusql-engine/src/ChuSQL/Syntax/AST.hs @@ -1,9 +1,12 @@ -module ChuSQL.Syntax.AST (Query (..), FromClause (..), Expr (..), SortDir (..), makeSelect) where +module ChuSQL.Syntax.AST (Statement (..), FromClause (..), Expr (..), SortDir (..), makeSelect) where --- SQL 语法树:四类语句(SELECT / INSERT / DELETE / UPDATE)+ 表达式、FROM 从句、排序方向。 +import ChuSQL.Model (Column (..)) --- 查询语句 -data Query +-- 语法树:语句、数据来源、条件表达式、排序方向。 + +-- * 语句 +-- | 一条语句:查 / 加 / 删 / 改 / 建表 / 删表 +data Statement = Select { selectCols :: [String] , selectFrom :: FromClause @@ -14,15 +17,18 @@ data Query | Insert String [String] [Expr] | Delete String (Maybe Expr) | Update String [(String, Expr)] (Maybe Expr) + | CreateTable String [(String, Column)] + | DropTable String deriving (Show, Eq) --- FROM 从句:单表或 JOIN。 +-- | 数据来源:单表,或两表拼接 data FromClause = FromTable (Maybe String) String | FromJoin FromClause (Maybe String) String Expr deriving (Show, Eq) --- 条件表达式 +-- * 表达式 +-- | 条件表达式 data Expr = Col String | LitInt Int @@ -35,14 +41,16 @@ data Expr | Or Expr Expr deriving (Show, Eq) --- 排序方向 +-- * 排序 +-- | 排序方向 data SortDir = Asc | Desc deriving (Show, Eq) --- 便捷构造:造一条最简单的 SELECT(给测试和演示用)。 -makeSelect :: [String] -> String -> Maybe Expr -> Query +-- * 构造 +-- | 造一个最简单的 SELECT +makeSelect :: [String] -> String -> Maybe Expr -> Statement makeSelect cols tbl w = Select { selectCols = cols diff --git a/chusql-engine/src/ChuSQL/Syntax/Parser.hs b/chusql-engine/src/ChuSQL/Syntax/Parser.hs index 9b5cd77..4cbd4f5 100644 --- a/chusql-engine/src/ChuSQL/Syntax/Parser.hs +++ b/chusql-engine/src/ChuSQL/Syntax/Parser.hs @@ -1,7 +1,8 @@ {-# LANGUAGE ImportQualifiedPost #-} -module ChuSQL.Syntax.Parser (parseQuery) where +module ChuSQL.Syntax.Parser (parseStatement) where +import ChuSQL.Model (Column (..)) import ChuSQL.Syntax.AST import Control.Monad.Combinators.Expr (Operator (..), makeExprParser) import Data.Char (isAlpha, isAlphaNum, toLower) @@ -12,48 +13,50 @@ import Text.Megaparsec import Text.Megaparsec.Char import Text.Megaparsec.Char.Lexer qualified as Lxr --- SQL 解析器:把 SQL 文本解析成 AST。词法层管空白、关键字、标识符、字面量与符号; --- 语法层按优先级 比较 > AND > OR 自底向上组装表达式。 +-- SQL 解析:词法 + 语法,把文本变成语句树。 +-- * 词法 +-- | 解析器类型 type Parser = Parsec Void String --- * 词法层 --- | 跳过空白(含换行)。 +-- | 跳过空白 sc :: Parser () 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 notFollowedBy (satisfy isIdentChar) --- | 标识符:字母或下划线开头,其后可跟字母、数字或下划线。 +-- | 读一个名字 identifier :: Parser String identifier = lexeme $ do first <- satisfy (\ch -> isAlpha ch || ch == '_') rest <- many (satisfy isIdentChar) return (first : rest) --- | 标识符允许的字符:字母、数字或下划线。 +-- | 名字里允许的字符 isIdentChar :: Char -> Bool isIdentChar ch = isAlphaNum ch || ch == '_' --- | 十进制整数字面量。 +-- | 读一个整数 integer :: Parser Int integer = lexeme Lxr.decimal --- | 单引号字符串字面量,遵循标准 SQL 转义规则: +-- | 读一个字符串字面量 stringLit :: Parser String stringLit = lexeme $ do _ <- char '\'' @@ -61,8 +64,10 @@ stringLit = lexeme $ do _ <- char '\'' return s --- | 字符串内的一个字符:@''@ 折叠成一个单引号,其余字符(含反斜杠)原样保留。 --- 两个分支都不会在失败时消耗输入,因此可以安全地放进 'many'。 +{- | 字符串内的一个字符:@''@ 折叠成一个单引号,其余字符(含反斜杠)原样保留。 +两个分支都不会在失败时消耗输入,因此可以安全地放进 'many'。 +-} +-- | 字符串里的一个字符 stringChar :: Parser Char stringChar = choice @@ -70,31 +75,34 @@ stringChar = , satisfy (/= '\'') ] --- | 解析 ASC/DESC +-- * 子句 +-- | 升序或降序 sortDir :: Parser SortDir sortDir = (Desc <$ keyword "desc") <|> (Asc <$ keyword "asc") <|> pure Asc --- | 解析 LIMIT +-- | LIMIT 后面的整数 limitClause :: Parser Int limitClause = do keyword "limit" integer --- | 解析JOIN和限定列名 +-- | 能带表名的列名 qualifiedName :: Parser String qualifiedName = do first <- identifier rest <- many (symbol "." *> identifier) return (intercalate "." (first : rest)) --- * 表达式层 --- | 表达式入口,最低优先级。 +-- * 表达式 +-- | 读一个表达式 expr :: Parser Expr expr = makeExprParser atom operatorTable --- | 运算符表。注意 'makeExprParser' 要求各层按优先级从高到低排列: --- 比较运算符绑定最紧,'AND' 次之,'OR' 最松。 +{- | 运算符表。注意 'makeExprParser' 要求各层按优先级从高到低排列: +比较运算符绑定最紧,'AND' 次之,'OR' 最松。 +-} +-- | 运算符优先级(从紧到松) operatorTable :: [[Operator Parser Expr]] operatorTable = [ @@ -106,7 +114,7 @@ operatorTable = , [InfixL (Or <$ keyword "OR")] -- 最低优先级 ] --- | 原子表达式:字面量、列引用或括号表达式。 +-- | 最小的表达式单位 atom :: Parser Expr atom = choice @@ -116,23 +124,22 @@ atom = , between (symbol "(") (symbol ")") expr ] --- * 语句层 --- | 显式指定排序的语句 +-- | 一个排序列 orderItem :: Parser (String, SortDir) orderItem = do col <- qualifiedName dir <- sortDir return (col, dir) --- | @ORDER BY col [ASC|DESC], ...@。 +-- | ORDER BY 的列表 orderByClause :: Parser [(String, SortDir)] orderByClause = do keyword "order" keyword "by" sepBy1 orderItem (symbol ",") --- | @col = expr@,用于 UPDATE 的 SET 子句。 +-- | SET 里的一条赋值 assignment :: Parser (String, Expr) assignment = do col <- identifier @@ -140,9 +147,8 @@ assignment = do e <- expr return (col, e) --- * 从句层 --- | SQL 保留字,不能用作表别名 +-- | 保留字清单 reservedWords :: [String] reservedWords = [ "select" @@ -163,24 +169,28 @@ reservedWords = , "delete" , "update" , "set" + , "create" + , "table" ] --- | 表别名。别名不能是保留字:否则 @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 条件@。 +-- | 一个 JOIN ... ON joinClause :: Parser (Maybe String, String, Expr) joinClause = do keyword "join" @@ -189,14 +199,14 @@ joinClause = do cond <- expr return (mAlias, tbl, cond) --- | FROM 从句:一张表 + 任意多个 JOIN。 +-- | FROM 子句(可含多个 JOIN) fromClause :: Parser FromClause fromClause = do (mAlias, tbl) <- tableRef joins <- many joinClause return (foldl (\acc (a, t, c) -> FromJoin acc a t c) (FromTable mAlias tbl) joins) --- | 选择列表:@*@ 或逗号分隔的列名。 +-- | SELECT 的列清单 selectList :: Parser [String] selectList = choice @@ -204,9 +214,46 @@ selectList = , (:) <$> qualifiedName <*> many (symbol "," *> qualifiedName) ] --- | @SELECT ... FROM ... [WHERE ...]@。 -selectQuery :: Parser Query -selectQuery = do + +-- | 读 CREATE TABLE +createTableStatement :: Parser Statement +createTableStatement = do + keyword "create" + keyword "table" + name <- identifier + cols <- between (symbol "(") (symbol ")") (sepBy1 columnDef (symbol ",")) + return (CreateTable name cols) + +-- | 读 DROP TABLE +dropTableStatement :: Parser Statement +dropTableStatement = do + keyword "drop" + keyword "table" + name <- identifier + return (DropTable name) + +-- | 建表时的一列 +columnDef :: Parser (String, Column) +columnDef = do + name <- identifier + ty <- columnType + return (name, ty) + +-- | 列类型关键字 +columnType :: Parser Column +columnType = + (keyword "integer" >> pure TInt) + <|> (keyword "int" >> pure TInt) + <|> (keyword "varchar" >> pure TStr) + <|> (keyword "text" >> pure TStr) + <|> (keyword "str" >> pure TStr) + <|> (keyword "boolean" >> pure TBool) + <|> (keyword "bool" >> pure TBool) + +-- * 语句 +-- | 读 SELECT +selectStatement :: Parser Statement +selectStatement = do keyword "select" cols <- selectList keyword "from" @@ -223,9 +270,9 @@ selectQuery = do , selectLimit = mLimit } --- | @INSERT ... INTO ... VALUES ...@。 -insertQuery :: Parser Query -insertQuery = do +-- | 读 INSERT +insertStatement :: Parser Statement +insertStatement = do keyword "INSERT" keyword "INTO" tbl <- identifier @@ -242,18 +289,18 @@ insertQuery = do (sepBy1 atom (symbol ",")) return (Insert tbl cols vals) --- | @DELETE ... FROM ... [WHERE ...]@。 -deleteQuery :: Parser Query -deleteQuery = do +-- | 读 DELETE +deleteStatement :: Parser Statement +deleteStatement = do keyword "DELETE" keyword "FROM" tbl <- identifier mWhere <- optional (keyword "WHERE" *> expr) return (Delete tbl mWhere) --- | UPDATE ... SET col = val, ... [WHERE ...]@ -updateQuery :: Parser Query -updateQuery = do +-- | 读 UPDATE +updateStatement :: Parser Statement +updateStatement = do keyword "update" tbl <- identifier keyword "set" @@ -261,9 +308,19 @@ updateQuery = do mWhere <- optional (keyword "where" *> expr) return (Update tbl assigns mWhere) --- | 解析一条完整的语句;失败时返回可直接展示的错误信息。 -parseQuery :: String -> Either String Query -parseQuery input = - case runParser (sc *> (selectQuery <|> insertQuery <|> deleteQuery <|> updateQuery) <* sc <* eof) "" input of +-- * 入口 +-- | 解析总入口 +parseStatement :: String -> Either String Statement +parseStatement input = + case runParser (sc *> statementP <* sc <* eof) "" input of Left err -> Left (errorBundlePretty err) Right q -> Right q + where + -- \| 依次尝试各种语句 + statementP = + selectStatement + <|> insertStatement + <|> deleteStatement + <|> updateStatement + <|> createTableStatement + <|> dropTableStatement diff --git a/chusql-engine/test/Spec.hs b/chusql-engine/test/Spec.hs index 06a3415..f377aea 100644 --- a/chusql-engine/test/Spec.hs +++ b/chusql-engine/test/Spec.hs @@ -62,19 +62,19 @@ main = hspec $ do describe "ChuSQL.Syntax.Parser" $ do it "parses a simple SELECT" $ do - parseQuery "SELECT name FROM users" + parseStatement "SELECT name FROM users" `shouldBe` Right (makeSelect ["name"] "users" Nothing) it "parses SELECT *" $ do - parseQuery "SELECT * FROM users" + parseStatement "SELECT * FROM users" `shouldBe` Right (makeSelect ["*"] "users" Nothing) it "parses multiple columns" $ do - parseQuery "SELECT name, age FROM users" + parseStatement "SELECT name, age FROM users" `shouldBe` Right (makeSelect ["name", "age"] "users" Nothing) it "parses WHERE with >" $ do - parseQuery "SELECT name FROM users WHERE age > 18" + parseStatement "SELECT name FROM users WHERE age > 18" `shouldBe` Right ( makeSelect ["name"] @@ -83,7 +83,7 @@ main = hspec $ do ) it "parses AND" $ do - parseQuery "SELECT name FROM users WHERE age > 18 AND age < 30" + parseStatement "SELECT name FROM users WHERE age > 18 AND age < 30" `shouldBe` Right ( makeSelect ["name"] @@ -97,7 +97,7 @@ main = hspec $ do ) it "parses OR" $ do - parseQuery "SELECT name FROM users WHERE age < 18 OR age > 60" + parseStatement "SELECT name FROM users WHERE age < 18 OR age > 60" `shouldBe` Right ( makeSelect ["name"] @@ -111,7 +111,7 @@ main = hspec $ do ) it "is case-insensitive for keywords" $ do - parseQuery "select name from users where age > 18" + parseStatement "select name from users where age > 18" `shouldBe` Right ( makeSelect ["name"] @@ -120,11 +120,11 @@ main = hspec $ do ) it "tolerates extra whitespace" $ do - parseQuery "SELECT name FROM users" + parseStatement "SELECT name FROM users" `shouldBe` Right (makeSelect ["name"] "users" Nothing) it "parses a string literal in WHERE" $ do - parseQuery "SELECT name FROM users WHERE name = 'Alice'" + parseStatement "SELECT name FROM users WHERE name = 'Alice'" `shouldBe` Right ( makeSelect ["name"] @@ -133,7 +133,7 @@ main = hspec $ do ) it "uses standard SQL escaping: '' is one quote" $ do - parseQuery "SELECT name FROM users WHERE name = 'It''s ok'" + parseStatement "SELECT name FROM users WHERE name = 'It''s ok'" `shouldBe` Right ( makeSelect ["name"] @@ -142,7 +142,7 @@ main = hspec $ do ) it "treats a backslash as an ordinary character" $ do - parseQuery "SELECT name FROM users WHERE name = 'a\\b'" + parseStatement "SELECT name FROM users WHERE name = 'a\\b'" `shouldBe` Right ( makeSelect ["name"] @@ -151,7 +151,7 @@ main = hspec $ do ) it "parses an empty string literal" $ do - parseQuery "SELECT name FROM users WHERE name = ''" + parseStatement "SELECT name FROM users WHERE name = ''" `shouldBe` Right ( makeSelect ["name"] @@ -160,7 +160,7 @@ main = hspec $ do ) it "parses a string that is a single quote" $ do - parseQuery "SELECT name FROM users WHERE name = ''''" + parseStatement "SELECT name FROM users WHERE name = ''''" `shouldBe` Right ( makeSelect ["name"] @@ -169,15 +169,15 @@ main = hspec $ do ) it "rejects an unterminated string literal" $ do - parseQuery "SELECT name FROM users WHERE name = 'oops" + parseStatement "SELECT name FROM users WHERE name = 'oops" `shouldSatisfy` isLeft it "allows digits inside identifiers" $ do - parseQuery "SELECT user1 FROM users" + parseStatement "SELECT user1 FROM users" `shouldBe` Right (makeSelect ["user1"] "users" Nothing) it "honours parentheses and AND/OR precedence" $ do - parseQuery "SELECT name FROM users WHERE (age > 18 OR age < 5) AND name = 'Bob'" + parseStatement "SELECT name FROM users WHERE (age > 18 OR age < 5) AND name = 'Bob'" `shouldBe` Right ( makeSelect ["name"] @@ -194,16 +194,16 @@ main = hspec $ do ) it "rejects trailing junk after a complete query" $ do - parseQuery "SELECT name FROM users extra junk" + parseStatement "SELECT name FROM users extra junk" `shouldSatisfy` isLeft it "returns Left on missing table name" $ do - parseQuery "SELECT name FROM" + parseStatement "SELECT name FROM" `shouldSatisfy` isLeft 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" + parseStatement "SELECT name FROM users JOIN orders ON id = user_id" `shouldBe` Right ( Select { selectCols = ["name"] @@ -220,7 +220,7 @@ main = hspec $ do ) it "parses JOIN with aliases" $ do - parseQuery "SELECT u.name FROM users u JOIN orders o ON u.id = o.user_id" + parseStatement "SELECT u.name FROM users u JOIN orders o ON u.id = o.user_id" `shouldBe` Right ( Select { selectCols = ["u.name"] @@ -237,13 +237,13 @@ main = hspec $ do ) it "parses qualified column names" $ do - case parseQuery "SELECT u.name, o.product FROM users u JOIN orders o ON u.id = o.user_id" of + case parseStatement "SELECT u.name, o.product FROM users u JOIN orders o ON u.id = o.user_id" of Right q -> selectCols q `shouldBe` ["u.name", "o.product"] Left err -> expectationFailure err describe "ChuSQL.Engine (SELECT)" $ do it "returns all rows without WHERE" $ do - rowsOf (runQuery testDB (makeSelect ["*"] "users" Nothing)) + rowsOf (runStatement testDB (makeSelect ["*"] "users" Nothing)) `shouldBe` Right [ [("id", VInt 1), ("name", VStr "Alice"), ("age", VInt 25)] , [("id", VInt 2), ("name", VStr "Bob"), ("age", VInt 17)] @@ -252,7 +252,7 @@ main = hspec $ do it "filters with WHERE" $ do rowsOf - ( runQuery + ( runStatement testDB ( makeSelect ["name"] @@ -266,7 +266,7 @@ main = hspec $ do ] it "projects only the requested columns" $ do - rowsOf (runQuery testDB (makeSelect ["name"] "users" Nothing)) + rowsOf (runStatement testDB (makeSelect ["name"] "users" Nothing)) `shouldBe` Right [ [("name", VStr "Alice")] , [("name", VStr "Bob")] @@ -274,11 +274,11 @@ main = hspec $ do ] it "returns Left for an unknown table" $ do - runQuery testDB (makeSelect ["name"] "nonexistent" Nothing) + runStatement testDB (makeSelect ["name"] "nonexistent" Nothing) `shouldSatisfy` isLeft it "returns Left for an unknown column" $ do - runQuery + runStatement testDB ( makeSelect ["name"] @@ -288,31 +288,31 @@ main = hspec $ do `shouldSatisfy` isLeft it "returns Left when WHERE is not a boolean" $ do - runQuery testDB (makeSelect ["name"] "users" (Just (Col "age"))) + runStatement testDB (makeSelect ["name"] "users" (Just (Col "age"))) `shouldSatisfy` isLeft it "returns Left for an unknown projected column" $ do - runQuery testDB (makeSelect ["nope"] "users" Nothing) + runStatement testDB (makeSelect ["nope"] "users" Nothing) `shouldSatisfy` isLeft describe "ChuSQL.Engine (INSERT)" $ do it "parses a simple INSERT" $ do - parseQuery "INSERT INTO users (name, age) VALUES ('Dave', 22)" + parseStatement "INSERT INTO users (name, age) VALUES ('Dave', 22)" `shouldBe` Right (Insert "users" ["name", "age"] [LitStr "Dave", LitInt 22]) it "parses INSERT with a single column" $ do - parseQuery "INSERT INTO users (name) VALUES ('Eve')" + parseStatement "INSERT INTO users (name) VALUES ('Eve')" `shouldBe` Right (Insert "users" ["name"] [LitStr "Eve"]) it "parses INSERT case-insensitively" $ do - parseQuery "insert into users (name, age) values ('Dave', 22)" + parseStatement "insert into users (name, age) values ('Dave', 22)" `shouldBe` Right (Insert "users" ["name", "age"] [LitStr "Dave", LitInt 22]) it "executes INSERT and adds a row at the end" $ do - case runQuery testDB (Insert "users" ["name", "age"] [LitStr "Dave", LitInt 22]) of + case runStatement testDB (Insert "users" ["name", "age"] [LitStr "Dave", LitInt 22]) of Left err -> expectationFailure err Right (db', _) -> do - rowsOf (runQuery db' (makeSelect ["name", "age"] "users" Nothing)) + rowsOf (runStatement db' (makeSelect ["name", "age"] "users" Nothing)) `shouldBe` Right [ [("name", VStr "Alice"), ("age", VInt 25)] , [("name", VStr "Bob"), ("age", VInt 17)] @@ -321,76 +321,76 @@ main = hspec $ do ] it "does not modify the original database" $ do - case runQuery testDB (Insert "users" ["name", "age"] [LitStr "Dave", LitInt 22]) of + case runStatement testDB (Insert "users" ["name", "age"] [LitStr "Dave", LitInt 22]) of Left err -> expectationFailure err Right (_, _) -> do - rowsOf (runQuery testDB (makeSelect ["name"] "users" Nothing)) + rowsOf (runStatement testDB (makeSelect ["name"] "users" Nothing)) `shouldSatisfy` \r -> case r of Right rows -> length rows == 3 Left _ -> False it "returns Left when column count does not match value count" $ do - runQuery testDB (Insert "users" ["name", "age"] [LitStr "Dave"]) + runStatement testDB (Insert "users" ["name", "age"] [LitStr "Dave"]) `shouldSatisfy` isLeft it "returns Left for an unknown table" $ do - runQuery testDB (Insert "nonexistent" ["name"] [LitStr "X"]) + runStatement testDB (Insert "nonexistent" ["name"] [LitStr "X"]) `shouldSatisfy` isLeft it "returns Left when a value has the wrong type for comparison" $ do - runQuery testDB (Insert "users" ["name"] [Col "other"]) + runStatement testDB (Insert "users" ["name"] [Col "other"]) `shouldSatisfy` isLeft describe "ChuSQL.Engine (DELETE)" $ do it "parses DELETE with WHERE" $ do - parseQuery "DELETE FROM users WHERE age < 18" + parseStatement "DELETE FROM users WHERE age < 18" `shouldBe` Right (Delete "users" (Just (Lt (Col "age") (LitInt 18)))) it "parses DELETE without WHERE" $ do - parseQuery "DELETE FROM users" + parseStatement "DELETE FROM users" `shouldBe` Right (Delete "users" Nothing) it "parses DELETE case-insensitively" $ do - parseQuery "delete from users where age < 18" + parseStatement "delete from users where age < 18" `shouldBe` Right (Delete "users" (Just (Lt (Col "age") (LitInt 18)))) it "executes DELETE with WHERE, removing matching rows" $ do - case runQuery testDB (Delete "users" (Just (Lt (Col "age") (LitInt 18)))) of + case runStatement testDB (Delete "users" (Just (Lt (Col "age") (LitInt 18)))) of Left err -> expectationFailure err Right (db', _) -> do - rowsOf (runQuery db' (makeSelect ["name"] "users" Nothing)) + rowsOf (runStatement db' (makeSelect ["name"] "users" Nothing)) `shouldBe` Right [ [("name", VStr "Alice")] , [("name", VStr "Carol")] ] it "executes DELETE without WHERE, removing all rows" $ do - case runQuery testDB (Delete "users" Nothing) of + case runStatement testDB (Delete "users" Nothing) of Left err -> expectationFailure err Right (db', _) -> do - rowsOf (runQuery db' (makeSelect ["name"] "users" Nothing)) + rowsOf (runStatement db' (makeSelect ["name"] "users" Nothing)) `shouldBe` Right [] it "does not modify the original database" $ do - case runQuery testDB (Delete "users" Nothing) of + case runStatement testDB (Delete "users" Nothing) of Left err -> expectationFailure err Right (_, _) -> do - rowsOf (runQuery testDB (makeSelect ["name"] "users" Nothing)) + rowsOf (runStatement testDB (makeSelect ["name"] "users" Nothing)) `shouldSatisfy` \r -> case r of Right rows -> length rows == 3 Left _ -> False it "returns Left for an unknown table" $ do - runQuery testDB (Delete "nonexistent" Nothing) + runStatement testDB (Delete "nonexistent" Nothing) `shouldSatisfy` isLeft it "returns Left when WHERE references an unknown column" $ do - runQuery testDB (Delete "users" (Just (Gt (Col "unknown") (LitInt 18)))) + runStatement testDB (Delete "users" (Just (Gt (Col "unknown") (LitInt 18)))) `shouldSatisfy` isLeft describe "ChuSQL.Engine (UPDATE)" $ do it "parses UPDATE with WHERE" $ do - parseQuery "UPDATE users SET age = 26 WHERE name = 'Alice'" + parseStatement "UPDATE users SET age = 26 WHERE name = 'Alice'" `shouldBe` Right ( Update "users" @@ -399,12 +399,12 @@ main = hspec $ do ) it "parses UPDATE without WHERE" $ do - parseQuery "UPDATE users SET age = 26" + parseStatement "UPDATE users SET age = 26" `shouldBe` Right (Update "users" [("age", LitInt 26)] Nothing) it "parses multiple assignments" $ do - parseQuery "UPDATE users SET age = 26, name = 'Dave'" + parseStatement "UPDATE users SET age = 26, name = 'Dave'" `shouldBe` Right ( Update "users" @@ -413,7 +413,7 @@ main = hspec $ do ) it "parses UPDATE case-insensitively" $ do - parseQuery "update users set age = 26 where name = 'Alice'" + parseStatement "update users set age = 26 where name = 'Alice'" `shouldBe` Right ( Update "users" @@ -422,10 +422,10 @@ main = hspec $ do ) it "executes UPDATE with WHERE, modifying matching rows" $ do - case runQuery testDB (Update "users" [("age", LitInt 99)] (Just (Eq (Col "name") (LitStr "Alice")))) of + case runStatement testDB (Update "users" [("age", LitInt 99)] (Just (Eq (Col "name") (LitStr "Alice")))) of Left err -> expectationFailure err Right (db', _) -> do - rowsOf (runQuery db' (makeSelect ["name", "age"] "users" Nothing)) + rowsOf (runStatement db' (makeSelect ["name", "age"] "users" Nothing)) `shouldBe` Right [ [("name", VStr "Alice"), ("age", VInt 99)] , [("name", VStr "Bob"), ("age", VInt 17)] @@ -433,10 +433,10 @@ main = hspec $ do ] it "executes UPDATE without WHERE, modifying all rows" $ do - case runQuery testDB (Update "users" [("age", LitInt 0)] Nothing) of + case runStatement testDB (Update "users" [("age", LitInt 0)] Nothing) of Left err -> expectationFailure err Right (db', _) -> do - rowsOf (runQuery db' (makeSelect ["name", "age"] "users" Nothing)) + rowsOf (runStatement db' (makeSelect ["name", "age"] "users" Nothing)) `shouldBe` Right [ [("name", VStr "Alice"), ("age", VInt 0)] , [("name", VStr "Bob"), ("age", VInt 0)] @@ -444,10 +444,10 @@ main = hspec $ do ] it "applies multiple assignments to each row" $ do - case runQuery testDB (Update "users" [("age", LitInt 99), ("name", LitStr "X")] Nothing) of + case runStatement testDB (Update "users" [("age", LitInt 99), ("name", LitStr "X")] Nothing) of Left err -> expectationFailure err Right (db', _) -> do - rowsOf (runQuery db' (makeSelect ["name", "age"] "users" Nothing)) + rowsOf (runStatement db' (makeSelect ["name", "age"] "users" Nothing)) `shouldBe` Right [ [("name", VStr "X"), ("age", VInt 99)] , [("name", VStr "X"), ("age", VInt 99)] @@ -455,51 +455,51 @@ main = hspec $ do ] it "does not modify the original database" $ do - case runQuery testDB (Update "users" [("age", LitInt 0)] Nothing) of + case runStatement testDB (Update "users" [("age", LitInt 0)] Nothing) of Left err -> expectationFailure err Right (_, _) -> do - rowsOf (runQuery testDB (makeSelect ["name"] "users" Nothing)) + rowsOf (runStatement testDB (makeSelect ["name"] "users" Nothing)) `shouldSatisfy` \r -> case r of Right rows -> length rows == 3 Left _ -> False it "returns Left for an unknown table" $ do - runQuery testDB (Update "nonexistent" [("age", LitInt 0)] Nothing) + runStatement testDB (Update "nonexistent" [("age", LitInt 0)] Nothing) `shouldSatisfy` isLeft it "returns Left when WHERE references an unknown column" $ do - runQuery testDB (Update "users" [("age", LitInt 0)] (Just (Gt (Col "unknown") (LitInt 1)))) + runStatement testDB (Update "users" [("age", LitInt 0)] (Just (Gt (Col "unknown") (LitInt 1)))) `shouldSatisfy` isLeft it "returns Left when SET references an unknown column" $ do - runQuery testDB (Update "users" [("unknown", LitInt 0)] Nothing) + runStatement testDB (Update "users" [("unknown", LitInt 0)] Nothing) `shouldSatisfy` \r -> case r of Right _ -> True Left _ -> True describe "ChuSQL.Engine (ORDER BY)" $ do it "parses ORDER BY ASC" $ do - case parseQuery "SELECT name FROM users ORDER BY age ASC" of + case parseStatement "SELECT name FROM users ORDER BY age ASC" of Right q -> selectOrderBy q `shouldBe` [("age", Asc)] Left err -> expectationFailure err it "parses ORDER BY DESC" $ do - case parseQuery "SELECT name FROM users ORDER BY age DESC" of + case parseStatement "SELECT name FROM users ORDER BY age DESC" of Right q -> selectOrderBy q `shouldBe` [("age", Desc)] Left err -> expectationFailure err it "defaults to ASC" $ do - case parseQuery "SELECT name FROM users ORDER BY age" of + case parseStatement "SELECT name FROM users ORDER BY age" of Right q -> selectOrderBy q `shouldBe` [("age", Asc)] Left err -> expectationFailure err it "parses multiple ORDER BY columns" $ do - case parseQuery "SELECT name FROM users ORDER BY age DESC, name ASC" of + case parseStatement "SELECT name FROM users ORDER BY age DESC, name ASC" of Right q -> selectOrderBy q `shouldBe` [("age", Desc), ("name", Asc)] Left err -> expectationFailure err it "parses without ORDER BY" $ do - case parseQuery "SELECT name FROM users" of + case parseStatement "SELECT name FROM users" of Right q -> selectOrderBy q `shouldBe` [] Left err -> expectationFailure err @@ -512,7 +512,7 @@ main = hspec $ do , selectOrderBy = [("age", Asc)] , selectLimit = Nothing } - rowsOf (runQuery testDB q) + rowsOf (runStatement testDB q) `shouldBe` Right [ [("name", VStr "Bob")] , [("name", VStr "Alice")] @@ -528,7 +528,7 @@ main = hspec $ do , selectOrderBy = [("age", Desc)] , selectLimit = Nothing } - rowsOf (runQuery testDB q) + rowsOf (runStatement testDB q) `shouldBe` Right [ [("name", VStr "Carol")] , [("name", VStr "Alice")] @@ -544,7 +544,7 @@ main = hspec $ do , selectOrderBy = [("name", Asc)] , selectLimit = Nothing } - rowsOf (runQuery testDB q) + rowsOf (runStatement testDB q) `shouldBe` Right [ [("name", VStr "Alice")] , [("name", VStr "Bob")] @@ -560,23 +560,23 @@ main = hspec $ do , selectOrderBy = [("nope", Asc)] , selectLimit = Nothing } - runQuery testDB q `shouldSatisfy` isLeft + runStatement testDB q `shouldSatisfy` isLeft describe "ChuSQL.Engine (LIMIT)" $ do it "parses LIMIT" $ do - case parseQuery "SELECT * FROM users LIMIT 2" of + case parseStatement "SELECT * FROM users LIMIT 2" of Right q -> selectLimit q `shouldBe` Just 2 Left err -> expectationFailure err it "parses LIMIT after ORDER BY" $ do - case parseQuery "SELECT * FROM users ORDER BY age DESC LIMIT 1" of + case parseStatement "SELECT * FROM users ORDER BY age DESC LIMIT 1" of Right q -> do selectOrderBy q `shouldBe` [("age", Desc)] selectLimit q `shouldBe` Just 1 Left err -> expectationFailure err it "parses without LIMIT" $ do - case parseQuery "SELECT * FROM users" of + case parseStatement "SELECT * FROM users" of Right q -> selectLimit q `shouldBe` Nothing Left err -> expectationFailure err @@ -589,7 +589,7 @@ main = hspec $ do , selectOrderBy = [] , selectLimit = Just 2 } - rowsOf (runQuery testDB q) + rowsOf (runStatement testDB q) `shouldBe` Right [ [("name", VStr "Alice")] , [("name", VStr "Bob")] @@ -604,7 +604,7 @@ main = hspec $ do , selectOrderBy = [("age", Desc)] , selectLimit = Just 2 } - rowsOf (runQuery testDB q) + rowsOf (runStatement testDB q) `shouldBe` Right [ [("name", VStr "Carol")] , [("name", VStr "Alice")] @@ -619,7 +619,7 @@ main = hspec $ do , selectOrderBy = [] , selectLimit = Just 10 } - rowsOf (runQuery testDB q) + rowsOf (runStatement testDB q) `shouldBe` Right [ [("name", VStr "Alice")] , [("name", VStr "Bob")] @@ -635,16 +635,16 @@ main = hspec $ do , selectOrderBy = [] , selectLimit = Just 0 } - rowsOf (runQuery testDB q) + rowsOf (runStatement testDB q) `shouldBe` Right [] it "rejects negative LIMIT" $ do - parseQuery "SELECT * FROM users LIMIT -1" + parseStatement "SELECT * FROM users LIMIT -1" `shouldSatisfy` isLeft 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) + rowsOf (parseStatement "SELECT u.name, o.product FROM users u JOIN orders o ON u.id = o.user_id" >>= runStatement testDB) `shouldBe` Right [ [("u.name", VStr "Alice"), ("o.product", VStr "Book")] , [("u.name", VStr "Alice"), ("o.product", VStr "Cup")] @@ -652,14 +652,14 @@ main = hspec $ do ] it "executes JOIN with WHERE" $ do - rowsOf (parseQuery "SELECT u.name, o.product FROM users u JOIN orders o ON u.id = o.user_id WHERE u.age > 20" >>= runQuery testDB) + rowsOf (parseStatement "SELECT u.name, o.product FROM users u JOIN orders o ON u.id = o.user_id WHERE u.age > 20" >>= runStatement testDB) `shouldBe` Right [ [("u.name", VStr "Alice"), ("o.product", VStr "Book")] , [("u.name", VStr "Alice"), ("o.product", VStr "Cup")] ] it "executes JOIN with ORDER BY" $ do - rowsOf (parseQuery "SELECT u.name, o.product FROM users u JOIN orders o ON u.id = o.user_id ORDER BY o.product DESC" >>= runQuery testDB) + rowsOf (parseStatement "SELECT u.name, o.product FROM users u JOIN orders o ON u.id = o.user_id ORDER BY o.product DESC" >>= runStatement testDB) `shouldBe` Right [ [("u.name", VStr "Bob"), ("o.product", VStr "Pen")] , [("u.name", VStr "Alice"), ("o.product", VStr "Cup")] @@ -667,14 +667,14 @@ main = hspec $ do ] it "executes JOIN with LIMIT" $ do - rowsOf (parseQuery "SELECT u.name, o.product FROM users u JOIN orders o ON u.id = o.user_id LIMIT 2" >>= runQuery testDB) + rowsOf (parseStatement "SELECT u.name, o.product FROM users u JOIN orders o ON u.id = o.user_id LIMIT 2" >>= runStatement testDB) `shouldBe` Right [ [("u.name", VStr "Alice"), ("o.product", VStr "Book")] , [("u.name", VStr "Alice"), ("o.product", VStr "Cup")] ] it "returns Left when JOIN table not found" $ do - runQuery + runStatement testDB ( Select { selectCols = ["u.name"] @@ -692,7 +692,7 @@ main = hspec $ do `shouldSatisfy` isLeft it "returns Left when ON references an unknown column" $ do - runQuery + runStatement testDB ( Select { selectCols = ["u.name"] @@ -711,7 +711,7 @@ main = hspec $ do 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) + rowsOf (parseStatement "SELECT name FROM users WHERE age > 18" >>= runStatement testDB) `shouldBe` Right [ [("name", VStr "Alice")] , [("name", VStr "Carol")] @@ -719,8 +719,8 @@ main = hspec $ do it "parses and executes a complex query" $ do rowsOf - ( parseQuery "SELECT name FROM users WHERE age < 18 OR age > 28" - >>= runQuery testDB + ( parseStatement "SELECT name FROM users WHERE age < 18 OR age > 28" + >>= runStatement testDB ) `shouldBe` Right [ [("name", VStr "Bob")] @@ -728,14 +728,14 @@ main = hspec $ do ] it "returns Left when parsing fails" $ do - (parseQuery "SELECT name FROM" >>= runQuery testDB) + (parseStatement "SELECT name FROM" >>= runStatement testDB) `shouldSatisfy` isLeft it "INSERT then SELECT end-to-end" $ do - case parseQuery "INSERT INTO users (name, age) VALUES ('Dave', 22)" >>= runQuery testDB of + case parseStatement "INSERT INTO users (name, age) VALUES ('Dave', 22)" >>= runStatement testDB of Left err -> expectationFailure err Right (db', _) -> do - rowsOf (parseQuery "SELECT name FROM users WHERE age > 18" >>= runQuery db') + rowsOf (parseStatement "SELECT name FROM users WHERE age > 18" >>= runStatement db') `shouldBe` Right [ [("name", VStr "Alice")] , [("name", VStr "Carol")] @@ -743,19 +743,19 @@ main = hspec $ do ] it "DELETE then SELECT end-to-end" $ do - case parseQuery "DELETE FROM users WHERE age > 20" >>= runQuery testDB of + case parseStatement "DELETE FROM users WHERE age > 20" >>= runStatement testDB of Left err -> expectationFailure err Right (db', _) -> do - rowsOf (parseQuery "SELECT name FROM users" >>= runQuery db') + rowsOf (parseStatement "SELECT name FROM users" >>= runStatement db') `shouldBe` Right [ [("name", VStr "Bob")] ] it "UPDATE then SELECT end-to-end" $ do - case parseQuery "UPDATE users SET age = 99 WHERE name = 'Alice'" >>= runQuery testDB of + case parseStatement "UPDATE users SET age = 99 WHERE name = 'Alice'" >>= runStatement testDB of Left err -> expectationFailure err Right (db', _) -> do - rowsOf (parseQuery "SELECT name, age FROM users" >>= runQuery db') + rowsOf (parseStatement "SELECT name, age FROM users" >>= runStatement db') `shouldBe` Right [ [("name", VStr "Alice"), ("age", VInt 99)] , [("name", VStr "Bob"), ("age", VInt 17)] @@ -763,27 +763,27 @@ main = hspec $ do ] it "ORDER BY with WHERE end-to-end" $ do - rowsOf (parseQuery "SELECT name FROM users WHERE age > 18 ORDER BY age DESC" >>= runQuery testDB) + rowsOf (parseStatement "SELECT name FROM users WHERE age > 18 ORDER BY age DESC" >>= runStatement testDB) `shouldBe` Right [ [("name", VStr "Carol")] , [("name", VStr "Alice")] ] it "ORDER BY with LIMIT end-to-end" $ do - rowsOf (parseQuery "SELECT name FROM users ORDER BY age DESC LIMIT 2" >>= runQuery testDB) + rowsOf (parseStatement "SELECT name FROM users ORDER BY age DESC LIMIT 2" >>= runStatement testDB) `shouldBe` Right [ [("name", VStr "Carol")] , [("name", VStr "Alice")] ] it "WHERE ORDER BY LIMIT end-to-end" $ do - rowsOf (parseQuery "SELECT name FROM users WHERE age > 15 ORDER BY age DESC LIMIT 1" >>= runQuery testDB) + rowsOf (parseStatement "SELECT name FROM users WHERE age > 15 ORDER BY age DESC LIMIT 1" >>= runStatement testDB) `shouldBe` Right [ [("name", VStr "Carol")] ] it "JOIN with WHERE and ORDER BY end-to-end" $ do - rowsOf (parseQuery "SELECT u.name, o.product FROM users u JOIN orders o ON u.id = o.user_id WHERE u.age > 15 ORDER BY o.product" >>= runQuery testDB) + rowsOf (parseStatement "SELECT u.name, o.product FROM users u JOIN orders o ON u.id = o.user_id WHERE u.age > 15 ORDER BY o.product" >>= runStatement testDB) `shouldBe` Right [ [("u.name", VStr "Alice"), ("o.product", VStr "Book")] , [("u.name", VStr "Alice"), ("o.product", VStr "Cup")] @@ -804,7 +804,7 @@ main = hspec $ do sameResultAsUnoptimized "SELECT * FROM users" it "pushes a single-side predicate into the left side of a JOIN" $ do - case parseQuery "SELECT u.name FROM users u JOIN orders o ON u.id = o.user_id WHERE u.age > 20" of + case parseStatement "SELECT u.name FROM users u JOIN orders o ON u.id = o.user_id WHERE u.age > 20" of Left err -> expectationFailure err Right q -> case translate q of @@ -813,7 +813,7 @@ main = hspec $ do filterPushedIntoLeft (optimize testDB relOp) `shouldBe` True it "removes a redundant SELECT * projection" $ do - case parseQuery "SELECT * FROM users" of + case parseStatement "SELECT * FROM users" of Left err -> expectationFailure err Right q -> case translate q of @@ -827,7 +827,7 @@ main = hspec $ do optimize testDB relOp `shouldBe` relOp it "is idempotent after reaching a fixed point" $ do - case parseQuery "SELECT u.name, o.product FROM users u JOIN orders o ON u.id = o.user_id WHERE u.age > 20" of + case parseStatement "SELECT u.name, o.product FROM users u JOIN orders o ON u.id = o.user_id WHERE u.age > 20" of Left err -> expectationFailure err Right q -> case translate q of @@ -872,11 +872,11 @@ main = hspec $ do sameResultAsUnoptimized "SELECT name FROM users WHERE 'abc' > 1" it "returns no rows for a constant-false predicate" $ do - rowsOf (parseQuery "SELECT name FROM users WHERE 1 > 2" >>= runQuery testDB) + rowsOf (parseStatement "SELECT name FROM users WHERE 1 > 2" >>= runStatement testDB) `shouldBe` Right [] it "returns every row for a constant-true predicate" $ do - rowsOf (parseQuery "SELECT name FROM users WHERE 1 = 1" >>= runQuery testDB) + rowsOf (parseStatement "SELECT name FROM users WHERE 1 = 1" >>= runStatement testDB) `shouldBe` Right [ [("name", VStr "Alice")] , [("name", VStr "Bob")] @@ -961,123 +961,123 @@ main = hspec $ do describe "ChuSQL.Semantic" $ do it "rejects an unknown column in WHERE" $ do - (parseQuery "SELECT name FROM users WHERE nope > 18" >>= runQuery testDB) + (parseStatement "SELECT name FROM users WHERE nope > 18" >>= runStatement testDB) `shouldSatisfy` isLeft it "rejects an unknown column in WHERE even when the table is empty" $ do - (parseQuery "SELECT name FROM users WHERE nope > 18" >>= runQuery emptyDB) + (parseStatement "SELECT name FROM users WHERE nope > 18" >>= runStatement emptyDB) `shouldSatisfy` isLeft it "rejects an unknown column in an ON condition" $ do - (parseQuery "SELECT u.name FROM users u JOIN orders o ON u.nope = o.user_id" >>= runQuery testDB) + (parseStatement "SELECT u.name FROM users u JOIN orders o ON u.nope = o.user_id" >>= runStatement testDB) `shouldSatisfy` isLeft it "rejects a type mismatch inside a comparison" $ do - (parseQuery "SELECT name FROM users WHERE name > 18" >>= runQuery testDB) + (parseStatement "SELECT name FROM users WHERE name > 18" >>= runStatement testDB) `shouldSatisfy` isLeft it "rejects a type mismatch even when the table is empty" $ do - (parseQuery "SELECT name FROM users WHERE name > 18" >>= runQuery emptyDB) + (parseStatement "SELECT name FROM users WHERE name > 18" >>= runStatement emptyDB) `shouldSatisfy` isLeft it "rejects a WHERE clause that is not a condition" $ do - (parseQuery "SELECT name FROM users WHERE age" >>= runQuery testDB) + (parseStatement "SELECT name FROM users WHERE age" >>= runStatement testDB) `shouldSatisfy` isLeft it "rejects a non-boolean WHERE even when the table is empty" $ do - (parseQuery "SELECT name FROM users WHERE age" >>= runQuery emptyDB) + (parseStatement "SELECT name FROM users WHERE age" >>= runStatement emptyDB) `shouldSatisfy` isLeft it "rejects comparing two different types with =" $ do - (parseQuery "SELECT name FROM users WHERE name = 18" >>= runQuery testDB) + (parseStatement "SELECT name FROM users WHERE name = 18" >>= runStatement testDB) `shouldSatisfy` isLeft it "rejects an unknown column in INSERT" $ do - (parseQuery "INSERT INTO users (nickname) VALUES (1)" >>= runQuery testDB) + (parseStatement "INSERT INTO users (nickname) VALUES (1)" >>= runStatement testDB) `shouldSatisfy` isLeft it "rejects a value whose type does not match the column" $ do - (parseQuery "INSERT INTO users (name, age) VALUES ('Dave', 'abc')" >>= runQuery testDB) + (parseStatement "INSERT INTO users (name, age) VALUES ('Dave', 'abc')" >>= runStatement testDB) `shouldSatisfy` isLeft it "rejects an unknown column in UPDATE SET" $ do - (parseQuery "UPDATE users SET nope = 1" >>= runQuery testDB) + (parseStatement "UPDATE users SET nope = 1" >>= runStatement testDB) `shouldSatisfy` isLeft it "rejects an unknown column in DELETE WHERE" $ do - (parseQuery "DELETE FROM users WHERE nope > 1" >>= runQuery testDB) + (parseStatement "DELETE FROM users WHERE nope > 1" >>= runStatement testDB) `shouldSatisfy` isLeft it "still accepts a valid query" $ do - rowsOf (parseQuery "SELECT name FROM users WHERE age > 18" >>= runQuery testDB) + rowsOf (parseStatement "SELECT name FROM users WHERE age > 18" >>= runStatement testDB) `shouldBe` Right [ [("name", VStr "Alice")] , [("name", VStr "Carol")] ] it "rejects an UPDATE whose value has the wrong type" $ do - (parseQuery "UPDATE users SET age = 'abc'" >>= runQuery testDB) + (parseStatement "UPDATE users SET age = 'abc'" >>= runStatement testDB) `shouldSatisfy` isLeft it "rejects a type mismatch in an ON condition" $ do - (parseQuery "SELECT u.name FROM users u JOIN orders o ON u.name = o.user_id" >>= runQuery testDB) + (parseStatement "SELECT u.name FROM users u JOIN orders o ON u.name = o.user_id" >>= runStatement testDB) `shouldSatisfy` isLeft it "rejects an unknown column in DELETE WHERE even when the table is empty" $ do - (parseQuery "DELETE FROM users WHERE nope > 1" >>= runQuery emptyDB) + (parseStatement "DELETE FROM users WHERE nope > 1" >>= runStatement emptyDB) `shouldSatisfy` isLeft it "rejects an unknown column in UPDATE SET even when the table is empty" $ do - (parseQuery "UPDATE users SET nope = 1" >>= runQuery emptyDB) + (parseStatement "UPDATE users SET nope = 1" >>= runStatement emptyDB) `shouldSatisfy` isLeft it "currently allows an INSERT that leaves a column out (known gap)" $ do - (parseQuery "INSERT INTO users (name) VALUES ('Eve')" >>= runQuery testDB) + (parseStatement "INSERT INTO users (name) VALUES ('Eve')" >>= runStatement testDB) `shouldSatisfy` isRight it "says which clause is wrong and which columns are available" $ do - case parseQuery "SELECT name FROM users WHERE nope > 18" >>= runQuery testDB of + case parseStatement "SELECT name FROM users WHERE nope > 18" >>= runStatement testDB of Right _ -> expectationFailure "expected a Left" Left err -> do err `shouldContain` "unknown column in WHERE" err `shouldContain` "available: id, name, age" it "names INSERT when a target column does not exist" $ do - case parseQuery "INSERT INTO users (nickname) VALUES (1)" >>= runQuery testDB of + case parseStatement "INSERT INTO users (nickname) VALUES (1)" >>= runStatement testDB of Right _ -> expectationFailure "expected a Left" Left err -> err `shouldContain` "unknown column in INSERT" it "names ORDER BY when a sort key does not exist" $ do - case parseQuery "SELECT name FROM users ORDER BY nope" >>= runQuery testDB of + case parseStatement "SELECT name FROM users ORDER BY nope" >>= runStatement testDB of Right _ -> expectationFailure "expected a Left" Left err -> err `shouldContain` "unknown column in ORDER BY" it "names ON and both types when a join condition mismatches" $ do - case parseQuery "SELECT u.name FROM users u JOIN orders o ON u.name = o.user_id" >>= runQuery testDB of + case parseStatement "SELECT u.name FROM users u JOIN orders o ON u.name = o.user_id" >>= runStatement testDB of Right _ -> expectationFailure "expected a Left" Left err -> do err `shouldContain` "ON: both sides of =" err `shouldContain` "TStr and TInt" it "names the column and the expected type when an assignment has the wrong type" $ do - case parseQuery "UPDATE users SET age = 'abc'" >>= runQuery testDB of + case parseStatement "UPDATE users SET age = 'abc'" >>= runStatement testDB of Right _ -> expectationFailure "expected a Left" Left err -> err `shouldContain` "UPDATE: column age needs TInt, got TStr" it "accepts SELECT * across a join (the sentinel is not a column)" $ do - (parseQuery "SELECT * FROM users u JOIN orders o ON u.id = o.user_id" >>= runQuery testDB) + (parseStatement "SELECT * FROM users u JOIN orders o ON u.id = o.user_id" >>= runStatement testDB) `shouldSatisfy` isRight describe "ChuSQL.Algebra.Op" $ do it "renders a plan as indented text" $ do - case parseQuery "SELECT name FROM users WHERE age > 18" >>= translate of + case parseStatement "SELECT name FROM users WHERE age > 18" >>= translate of Left err -> expectationFailure err Right plan -> renderPlan plan `shouldBe` "Project [\"name\"]\n Filter Gt (Col \"age\") (LitInt 18)\n Scan Nothing \"users\"" it "renders a join plan with indentation" $ do - case parseQuery "SELECT u.name FROM users u JOIN orders o ON u.id = o.user_id" >>= translate of + case parseStatement "SELECT u.name FROM users u JOIN orders o ON u.id = o.user_id" >>= translate of Left err -> expectationFailure err Right plan -> lines (renderPlan plan) @@ -1088,7 +1088,7 @@ main = hspec $ do ] it "renders a sort and limit plan" $ do - case parseQuery "SELECT name FROM users ORDER BY age DESC LIMIT 2" >>= translate of + case parseStatement "SELECT name FROM users ORDER BY age DESC LIMIT 2" >>= translate of Left err -> expectationFailure err Right plan -> lines (renderPlan plan) @@ -1180,7 +1180,67 @@ main = hspec $ do withPipeName "chusql-no-such-server" $ do result <- runIPCStorage (scan "users") result `shouldSatisfy` isLeft + it "parses CREATE TABLE with two columns" $ do + parseStatement "CREATE TABLE users (id INT, name TEXT)" + `shouldBe` Right (CreateTable "users" [("id", TInt), ("name", TStr)]) + it "parses CREATE TABLE case-insensitively" $ do + parseStatement "create table users (id int, name text)" + `shouldBe` Right (CreateTable "users" [("id", TInt), ("name", TStr)]) + + it "parses CREATE TABLE with BOOL column" $ do + parseStatement "CREATE TABLE t (flag BOOL)" + `shouldBe` Right (CreateTable "t" [("flag", TBool)]) + + it "parses CREATE TABLE with VARCHAR as TStr" $ do + parseStatement "CREATE TABLE t (name VARCHAR)" + `shouldBe` Right (CreateTable "t" [("name", TStr)]) + + it "parses CREATE TABLE with INTEGER" $ do + parseStatement "CREATE TABLE t (id INTEGER)" + `shouldBe` Right (CreateTable "t" [("id", TInt)]) + + it "rejects CREATE TABLE without columns" $ do + parseStatement "CREATE TABLE t ()" + `shouldSatisfy` isLeft + + it "rejects CREATE TABLE with missing type" $ do + parseStatement "CREATE TABLE t (id)" + `shouldSatisfy` isLeft + + describe "ChuSQL.Engine (CREATE TABLE)" $ do + it "creates a table in memory" $ do + case runStatement testDB (CreateTable "newt" [("id", TInt), ("name", TStr)]) of + Left err -> expectationFailure err + Right (db', _) -> do + rowsOf (runStatement db' (makeSelect ["*"] "newt" Nothing)) + `shouldBe` Right [] + + it "rejects creating an existing table" $ do + runStatement testDB (CreateTable "users" [("id", TInt)]) + `shouldSatisfy` isLeft + + it "rejects duplicate column names" $ do + runStatement testDB (CreateTable "dup" [("id", TInt), ("id", TStr)]) + `shouldSatisfy` isLeft + + it "rejects empty column list at parse time" $ do + parseStatement "CREATE TABLE t ()" + `shouldSatisfy` isLeft + it "parses DROP TABLE" $ do + parseStatement "DROP TABLE users" + `shouldBe` Right (DropTable "users") + + it "drops a table in memory" $ do + case runStatement testDB (DropTable "users") of + Left e -> expectationFailure e + Right (db', _) -> do + runStatement db' (makeSelect ["*"] "users" Nothing) + `shouldSatisfy` isLeft + + it "rejects dropping a nonexistent table" $ do + runStatement testDB (DropTable "nope") + `shouldSatisfy` isLeft emptyDB :: Database emptyDB = [(n, t{tableRows = []}) | (n, t) <- testDB] @@ -1305,7 +1365,7 @@ isRight (Left _) = False sameResultAsUnoptimized :: String -> Expectation sameResultAsUnoptimized sql = - case parseQuery sql of + case parseStatement sql of Left err -> expectationFailure err Right q -> case translate q of @@ -1319,7 +1379,7 @@ filterPushedIntoLeft _ = False optimizedPlan :: String -> Either String RelOp optimizedPlan sql = do - q <- parseQuery sql + q <- parseStatement sql optimize testDB <$> translate q firstFilterCond :: RelOp -> Maybe Expr @@ -1353,7 +1413,7 @@ stripProjects (Lookup t k) = Lookup t k projectedPlan :: String -> Either String RelOp projectedPlan sql = do - q <- parseQuery sql + q <- parseStatement sql plan <- translate q Right (pushProject testDB ["*"] plan) diff --git a/chusql-storage/src/bin/server.rs b/chusql-storage/src/bin/server.rs index ab78164..a4387b9 100644 --- a/chusql-storage/src/bin/server.rs +++ b/chusql-storage/src/bin/server.rs @@ -1,17 +1,15 @@ -//! 命名管道服务:按行读写 JSON。Windows 上管道名映射为 \\.\pipe\。 -//! 启动时读配置(环境变量 → TOML → 默认值)并把每项来源打进日志。 -use std::collections::BTreeSet; +use std::collections::{BTreeSet, HashMap}; use std::io::{BufRead, BufReader, Write}; use std::path::PathBuf; -use std::sync::Arc; +use std::sync::{Arc, Mutex}; 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::protocol::{Request, Response, SchemaColumn}; use chusql_storage::wal::{Wal, WalOp}; use chusql_storage::{log_debug, log_error, log_info, log_warn}; use interprocess::local_socket::{ @@ -21,308 +19,413 @@ use interprocess::local_socket::{ }; use interprocess::TryClone; -// * 路径 +// 命名管道服务:按行读写 JSON,Windows 上映射成 \\.\pipe\。 +// 启动时读配置并把每项来源打进日志;表句柄按名字缓存复用。 -/// 表名 -> 数据文件路径。 -fn table_path(cfg: &Config, table: &str) -> PathBuf { - cfg.data_dir.join(format!("{}.db", table)) +// 服务器 +/// 服务器状态:配置 + 表句柄缓存 + 写锁 +struct Server { + cfg: Config, + tables: Mutex>>>, + write_lock: Mutex<()>, } -/// 表名 -> 索引文件路径。 -fn index_path(cfg: &Config, table: &str) -> PathBuf { - cfg.data_dir.join(format!("{}.idx", table)) -} +impl Server { + /// 新建服务器状态 + fn new(cfg: Config) -> std::io::Result { + std::fs::create_dir_all(&cfg.data_dir)?; + Ok(Server { + cfg, + tables: Mutex::new(HashMap::new()), + write_lock: Mutex::new(()), + }) + } -/// 数据字典文件路径。 -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 table_path(&self, table: &str) -> PathBuf { + self.cfg.data_dir.join(format!("{}.db", table)) + } -// * 表操作 + /// 表名 -> 索引文件 + fn index_path(&self, table: &str) -> PathBuf { + self.cfg.data_dir.join(format!("{}.idx", table)) + } -/// 打开一张表,执行闭包;表不存在就创建。 -fn with_table( - cfg: &Config, - table: &str, - f: impl FnOnce(&mut HeapTable) -> std::io::Result, -) -> std::io::Result { - std::fs::create_dir_all(&cfg.data_dir)?; - let mut t = HeapTable::open(table_path(cfg, table), cfg.page_size)?; - f(&mut t) -} + /// 数据字典文件 + fn catalog_path(&self) -> PathBuf { + self.cfg.data_dir.join("catalog.json") + } -/// 打开带索引的表,执行闭包。 -fn with_indexed_table( - cfg: &Config, - table: &str, - f: impl FnOnce(&mut HeapTable) -> std::io::Result, -) -> std::io::Result { - 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) -} + /// WAL 文件 + fn wal_path(&self) -> PathBuf { + self.cfg.data_dir.join("wal.log") + } -/// 表文件是否存在且非空。 -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( - cfg: &Config, - table: &str, - f: impl FnOnce(&mut HeapTable) -> std::io::Result, -) -> std::io::Result { - if !table_exists(cfg, table) { - return Err(std::io::Error::new( +// 表句柄 + /// 表文件存在且非空 + fn table_exists(&self, table: &str) -> bool { + if self.tables.lock().unwrap().contains_key(table) { + return true; + } + if let Ok(c) = Catalog::load(self.catalog_path()) { + if c.describe(table).is_some() { + return true; + } + } + std::fs::metadata(self.table_path(table)) + .map(|m| m.len() > 0) + .unwrap_or(false) + } + + /// 拿表句柄(缓存复用) + fn table_handle(&self, table: &str) -> std::io::Result>> { + let mut map = self.tables.lock().unwrap(); + if let Some(h) = map.get(table) { + return Ok(h.clone()); + } + std::fs::create_dir_all(&self.cfg.data_dir)?; + let t = HeapTable::open_indexed( + self.table_path(table), + self.index_path(table), + self.cfg.page_size, + self.cfg.btree_order, + self.cfg.pool_size, + )?; + let h = Arc::new(Mutex::new(t)); + map.insert(table.to_string(), h.clone()); + Ok(h) + } + + /// 在表上跑闭包 + fn with_table( + &self, + table: &str, + f: impl FnOnce(&mut HeapTable) -> std::io::Result, + ) -> std::io::Result { + let h = self.table_handle(table)?; + let mut guard = h.lock().unwrap(); + f(&mut guard) + } + + /// 同上,但一定带索引 + fn with_indexed_table( + &self, + table: &str, + f: impl FnOnce(&mut HeapTable) -> std::io::Result, + ) -> std::io::Result { + self.with_table(table, f) + } + + /// 表不存在就报错 + fn with_existing_table( + &self, + table: &str, + f: impl FnOnce(&mut HeapTable) -> std::io::Result, + ) -> std::io::Result { + if !self.table_exists(table) { + return Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + format!("unknown table: {}", table), + )); + } + self.with_table(table, f) + } + +// 写入路径 + /// 把一条 WAL 操作落到磁盘 + fn apply_op(&self, op: &WalOp) -> std::io::Result<()> { + match op { + WalOp::Insert { table, row, key } => { + match key { + None => self.with_table(table, |t| t.insert(row))?, + Some(k) => self.with_indexed_table(table, |t| t.insert_keyed(row, *k))?, + } + let mut c = Catalog::load(self.catalog_path())?; + let row: chusql_storage::protocol::Row = row.clone().into_iter().collect(); + c.record_insert(table, &row); + c.save(self.catalog_path())?; + Ok(()) + } + WalOp::ReplaceAll { table, rows } => { + self.with_indexed_table(table, |t| t.replace_all(rows))?; + let mut c = Catalog::load(self.catalog_path())?; + let rows: Vec = + rows.iter().map(|r| r.clone().into_iter().collect()).collect(); + c.record_replace_all(table, &rows); + c.save(self.catalog_path())?; + Ok(()) + } + } + } + + /// 先写 WAL 再改数据 + fn write_via_wal(&self, op: &WalOp) -> std::io::Result<()> { + let _guard = self.write_lock.lock().unwrap(); + std::fs::create_dir_all(&self.cfg.data_dir)?; + let wal = Wal::new(self.wal_path()); + + wal.clear()?; + + wal.append(op)?; + + let result = self.apply_op(op); + + if result.is_ok() { + if let Err(e) = self.flush_op_tables(op) { + return Err(e); + } + } + + wal.clear()?; + result + } + + /// 把涉及的表写回磁盘 + fn flush_op_tables(&self, op: &WalOp) -> std::io::Result<()> { + match op { + WalOp::Insert { table, key, .. } => { + if key.is_some() { + self.with_indexed_table(table, |t| t.flush()) + } else { + self.with_table(table, |t| t.flush()) + } + } + WalOp::ReplaceAll { table, .. } => { + self.with_indexed_table(table, |t| t.flush()) + } + } + } + + /// 启动时重放残留 WAL + fn recover_wal(&self) -> std::io::Result<()> { + std::fs::create_dir_all(&self.cfg.data_dir)?; + let wal = Wal::new(self.wal_path()); + match wal.read()? { + None => Ok(()), + Some(op) => { + log_warn!(core, "replaying incomplete WAL entry"); + self.apply_op(&op)?; + wal.clear()?; + log_info!(core, "WAL replay complete"); + Ok(()) + } + } + } + + +// 请求分发 + /// 一条请求翻成一条响应 + fn handle_request(&self, req: Request) -> Response { + match req { + Request::Ping => { + log_debug!(pipe, "ping"); + Response::Pong + } + + Request::Scan { table } => { + log_debug!(pipe, "scan table={}", table); + + let result = self.with_existing_table(&table, |t| t.scan()); + + match result { + 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 self.write_via_wal(&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 self.with_indexed_table(&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 self.write_via_wal(&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 self.list_tables() { + 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 self.describe_table(&table) { + Ok((cols, count)) => Response::Schema { + columns: cols, + row_count: count, + }, + Err(e) => { + log_warn!(pipe, "describe_table {}: {}", table, e); + Response::Error { + message: format!("describe_table {}: {}", table, e), + } + } + } + } + + Request::CreateTable { table, columns } => { + log_debug!(pipe, "create_table table={} cols={}", table, columns.len()); + + let mut c = match Catalog::load(self.catalog_path()) { + Ok(c) => c, + Err(e) => { + return Response::Error { + message: format!("create_table {}: catalog: {}", table, e), + } + } + }; + if c.describe(&table).is_some() { + return Response::Error { + message: format!("create_table {}: table already exists", table), + }; + } + + if let Err(e) = self.with_table(&table, |_t| Ok(())) { + return Response::Error { + message: format!("create_table {}: {}", table, e), + }; + } + + if let Err(e) = c.create_table(&table, columns) { + return Response::Error { + message: format!("create_table {}: {}", table, e), + }; + } + if let Err(e) = c.save(self.catalog_path()) { + return Response::Error { + message: format!("create_table {}: catalog save: {}", table, e), + }; + } + + Response::Ok + } + Request::DropTable { table } => { + log_debug!(pipe, "drop_table table={}", table); + + let mut c = match Catalog::load(self.catalog_path()) { + Ok(c) => c, + Err(e) => return Response::Error { + message: format!("drop_table {}: catalog: {}", table, e), + }, + }; + if let Err(e) = c.drop_table(&table) { + return Response::Error { + message: format!("drop_table {}: {}", table, e), + }; + } + if let Err(e) = c.save(self.catalog_path()) { + return Response::Error { + message: format!("drop_table {}: catalog save: {}", table, e), + }; + } + + self.tables.lock().unwrap().remove(&table); + + let _ = std::fs::remove_file(self.table_path(&table)); + let _ = std::fs::remove_file(self.index_path(&table)); + + Response::Ok + } + } + } + +// 信息查询 + /// 表名:catalog 与目录取并集 + fn list_tables(&self) -> std::io::Result> { + let mut names = BTreeSet::new(); + + let c = Catalog::load(self.catalog_path())?; + for t in c.table_names() { + names.insert(t); + } + + let dir = &self.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 + fn describe_table(&self, table: &str) -> std::io::Result<(Vec, u64)> { + let c = Catalog::load(self.catalog_path())?; + if let Some(schema) = c.describe(table) { + return Ok((schema.columns.clone(), schema.row_count)); + } + if self.tables.lock().unwrap().contains_key(table) + || std::fs::metadata(self.table_path(table)).is_ok() + { + return Ok((Vec::new(), 0)); + } + 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, -) -> 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], -) -> 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> { - 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> { - 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<()> { +// 连接 +/// 处理一条连接 +fn handle(server: &Server, conn: LocalSocketStream) -> std::io::Result<()> { let mut writer = conn.try_clone()?; let reader = BufReader::new(conn); @@ -333,7 +436,7 @@ fn handle(cfg: &Config, conn: LocalSocketStream) -> std::io::Result<()> { } let resp = match serde_json::from_str::(&line) { - Ok(req) => handle_request(cfg, req), + Ok(req) => server.handle_request(req), Err(e) => { log_warn!(pipe, "bad request line: {}", e); Response::Error { @@ -349,19 +452,20 @@ fn handle(cfg: &Config, conn: LocalSocketStream) -> std::io::Result<()> { 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()); + log_info!(core, " {:<18} {} [{}]", name, value, origin.describe()); } } +/// 进程入口 fn main() -> std::io::Result<()> { let loaded = match config::load() { Ok(l) => l, @@ -376,15 +480,15 @@ fn main() -> std::io::Result<()> { log_info!(core, "chusql-storage {} starting", env!("CARGO_PKG_VERSION")); log_config(&loaded); - let cfg = Arc::new(loaded.config); + let server = Arc::new(Server::new(loaded.config)?); - // 启动时先重放 WAL,再做其他事 - if let Err(e) = recover_wal(&cfg) { + if let Err(e) = server.recover_wal() { log_error!(core, "WAL recovery failed: {}", e); return Err(e); } - let ns_name = cfg + let ns_name = server + .cfg .pipe_name .as_str() .to_ns_name::() @@ -394,16 +498,16 @@ fn main() -> std::io::Result<()> { log_info!( core, "listening on pipe {} (\\\\.\\pipe\\{})", - cfg.pipe_name, - cfg.pipe_name + server.cfg.pipe_name, + server.cfg.pipe_name ); for conn in listener.incoming() { let conn = conn?; - let cfg = Arc::clone(&cfg); + let server = Arc::clone(&server); thread::spawn(move || { log_debug!(pipe, "connection accepted"); - if let Err(e) = handle(&cfg, conn) { + if let Err(e) = handle(&server, conn) { log_error!(pipe, "connection error: {}", e); } }); diff --git a/chusql-storage/src/btree.rs b/chusql-storage/src/btree.rs index 2ec4503..5d4e536 100644 --- a/chusql-storage/src/btree.rs +++ b/chusql-storage/src/btree.rs @@ -3,100 +3,98 @@ 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 孩子页号 +// 磁盘 B+ 树:节点占一页,文件头记页大小与 order。 -/// 文件头放在这一页。 +// 布局常量 +/// 文件头放在第 0 页 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 字节的页吗。 +/// 这个 order 放得进一页吗 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。 +// 小端读写 +/// 读一个 u8 fn read_u8(p: &Page, off: usize) -> u8 { p.data[off] } -/// 写一个 u8。 +/// 写一个 u8 fn write_u8(p: &mut Page, off: usize, v: u8) { p.data[off] = v; } -/// 读一个小端 u16。 +/// 读一个小端 u16 fn read_u16(p: &Page, off: usize) -> u16 { u16::from_le_bytes([p.data[off], p.data[off + 1]]) } -/// 写一个小端 u16。 +/// 写一个小端 u16 fn write_u16(p: &mut Page, off: usize, v: u16) { p.data[off..off + 2].copy_from_slice(&v.to_le_bytes()); } -/// 读一个小端 u32。 +/// 读一个小端 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。 +/// 写一个小端 u32 fn write_u32(p: &mut Page, off: usize, v: u32) { p.data[off..off + 4].copy_from_slice(&v.to_le_bytes()); } -/// 读一个小端 u64。 +/// 读一个小端 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。 +/// 写一个小端 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); @@ -105,7 +103,7 @@ fn write_meta(page: &mut Page, page_size: usize, order: usize, root: PageId) { 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( @@ -127,7 +125,8 @@ fn check_meta(page: &Page, page_size: usize, order: usize) -> io::Result<()> { Ok(()) } -/// 一个节点在内存里的样子。 +// 节点编解码 +/// 节点在内存里的样子 enum NodeData { Leaf { keys: Vec, @@ -140,7 +139,7 @@ enum NodeData { }, } -/// 从一页里读出节点。 +/// 从一页读出节点 fn read_node(p: &Page, order: usize) -> NodeData { let ty = read_u8(p, OFF_TYPE); let n = read_u16(p, OFF_COUNT) as usize; @@ -167,7 +166,7 @@ fn read_node(p: &Page, order: usize) -> NodeData { } } -/// 把节点写回一页(先清零,避免残留)。 +/// 把节点写回一页 fn write_node(p: &mut Page, node: &NodeData, order: usize) { p.zero(); let payload = off_payload(order); @@ -196,7 +195,7 @@ fn write_node(p: &mut Page, node: &NodeData, order: usize) { } } -/// 二分找 key 应该下探到的孩子下标。 +/// 二分选孩子 fn child_index(keys: &[i64], key: i64) -> usize { let mut lo = 0; let mut hi = keys.len(); @@ -211,15 +210,21 @@ fn child_index(keys: &[i64], key: i64) -> usize { lo } -/// 磁盘版 B+ 树。 +// B+ 树 +/// 磁盘 B+ 树 pub struct DiskBTree { file: PageFile, order: usize, } impl DiskBTree { - /// 打开或新建索引文件;页大小 / order 和文件头里记的不一致就报错。 - pub fn open>(path: P, page_size: usize, order: usize) -> io::Result { + /// 打开或新建索引文件 + pub fn open>( + path: P, + page_size: usize, + order: usize, + pool_size: usize, + ) -> io::Result { if !fits_in_page(page_size, order) { return Err(io::Error::new( io::ErrorKind::InvalidInput, @@ -231,7 +236,7 @@ impl DiskBTree { ), )); } - let mut file = PageFile::open(path, page_size)?; + let mut file = PageFile::with_options(path, page_size, pool_size)?; if file.num_pages()? == 0 { let mut meta = Page::new(META_PAGE, page_size); write_meta(&mut meta, page_size, order, 0); @@ -243,26 +248,26 @@ impl DiskBTree { Ok(DiskBTree { file, order }) } - /// 读根页号;0 表示空树。 + /// 读根页号 fn root(&mut self) -> io::Result> { 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) -> 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 { Ok(self.file.append_page()?.id) } - /// 清空整棵树:截断文件后重写文件头(根页号归零)。 + /// 清空整棵树 pub fn clear(&mut self) -> io::Result<()> { let page_size = self.file.page_size(); self.file.truncate()?; @@ -272,12 +277,12 @@ impl DiskBTree { Ok(()) } - /// 空页,用来拼一个新节点。 + /// 空页,用来拼节点 fn new_page(&self, id: PageId) -> Page { Page::new(id, self.file.page_size()) } - /// 按 key 找 value。 + /// 按 key 找 value pub fn get(&mut self, key: i64) -> io::Result> { match self.root()? { None => Ok(None), @@ -285,7 +290,7 @@ impl DiskBTree { } } - /// 递归查找。 + /// 递归查找 fn get_rec(&mut self, page_id: PageId, key: i64) -> io::Result> { let p = self.file.read_page(page_id)?; match read_node(&p, self.order) { @@ -299,7 +304,7 @@ impl DiskBTree { } } - /// 插入 / 覆盖一个键值对。 + /// 插入或覆盖 pub fn insert(&mut self, key: i64, value: u64) -> io::Result<()> { match self.root()? { None => { @@ -317,7 +322,6 @@ impl DiskBTree { 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], @@ -334,7 +338,7 @@ impl DiskBTree { } } - /// 递归插入;返回可能上提的 (分隔键, 新右页号)。 + /// 递归插入,可能上提 fn insert_rec( &mut self, page_id: PageId, @@ -358,7 +362,6 @@ impl DiskBTree { 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); @@ -409,7 +412,6 @@ impl DiskBTree { 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); @@ -441,7 +443,7 @@ impl DiskBTree { } } - /// 按 key 升序返回所有键值对。 + /// 按键升序返回全部 pub fn iter_all(&mut self) -> io::Result> { let mut out = Vec::new(); if let Some(r) = self.root()? { @@ -450,7 +452,7 @@ impl DiskBTree { 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) { @@ -467,4 +469,10 @@ impl DiskBTree { } Ok(()) } + + /// 把脏页写回 + pub fn flush(&mut self) -> io::Result<()> { + self.file.flush() + } + } diff --git a/chusql-storage/src/catalog.rs b/chusql-storage/src/catalog.rs index 601e9f8..5188b9a 100644 --- a/chusql-storage/src/catalog.rs +++ b/chusql-storage/src/catalog.rs @@ -1,5 +1,3 @@ -//! 数据字典:记录每张表有哪些列、什么类型。 -//! 存在 data/catalog.json。insert 时自动补列,describe 时读回。 use std::collections::BTreeMap; use std::io; @@ -9,20 +7,27 @@ use serde::{Deserialize, Serialize}; use crate::protocol::{ColumnType, Row, SchemaColumn}; -/// 一张表的 schema。 +// 数据字典:记每张表有哪些列、什么类型、多少行。 + #[derive(Debug, Clone, Default, Serialize, Deserialize)] +/// 一张表的 schema pub struct TableSchema { pub columns: Vec, + #[serde(default)] + pub row_count: u64, } -/// 整个数据字典。 #[derive(Debug, Default, Serialize, Deserialize)] +/// 数据字典:表名到 schema pub struct Catalog { tables: BTreeMap, } + + impl Catalog { - /// 从文件读;文件不存在返回空字典。 + // 读取与保存 + /// 从文件读;不存在给空字典 pub fn load>(path: P) -> io::Result { if !path.as_ref().exists() { return Ok(Catalog::default()); @@ -34,23 +39,25 @@ impl Catalog { serde_json::from_slice(&bytes).map_err(io::Error::other) } - /// 写回文件。 + /// 写回文件 pub fn save>(&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。 + // 查询 + /// 一张表的 schema pub fn describe(&self, table: &str) -> Option<&TableSchema> { self.tables.get(table) } - /// catalog 里记录的所有表名。 + /// 记录过的所有表名 pub fn table_names(&self) -> Vec { 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 { @@ -66,15 +73,74 @@ impl Catalog { } } - /// 按多行数据补列;replace_all 用。 + /// 建表;已存在报错 + pub fn create_table(&mut self, table: &str, columns: Vec) -> io::Result<()> { + if self.tables.contains_key(table) { + return Err(io::Error::new( + io::ErrorKind::AlreadyExists, + format!("table already exists: {}", table), + )); + } + self.tables.insert( + table.to_string(), + TableSchema { + columns, + row_count: 0, + }, + ); + Ok(()) + } + + /// 按多行数据补列 pub fn ensure_columns_from_rows(&mut self, table: &str, rows: &[Row]) { for r in rows { self.ensure_columns(table, r); } } + /// 记一次插入:补列 + 行数 +1 + pub fn record_insert(&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 }); + } + } + entry.row_count += 1; + } + + /// 记一次整表替换:列照补,行数重置 + pub fn record_replace_all(&mut self, table: &str, rows: &[Row]) { + let entry = self.tables.entry(table.to_string()).or_default(); + for r in rows { + for (k, v) in r { + 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 }); + } + } + } + entry.row_count = rows.len() as u64; + } + + /// 删表 + pub fn drop_table(&mut self, table: &str) -> io::Result<()> { + if self.tables.remove(table).is_none() { + return Err(io::Error::new( + io::ErrorKind::NotFound, + format!("unknown table: {}", table), + )); + } + Ok(()) + } } -/// 从 JSON 值推断列类型;不支持的类型返回 None。 +// 推断 +/// 由 JSON 值猜列类型 fn infer_type(v: &serde_json::Value) -> Option { match v { serde_json::Value::Number(_) => Some(ColumnType::Int), diff --git a/chusql-storage/src/config.rs b/chusql-storage/src/config.rs index 2f616e7..1a3893f 100644 --- a/chusql-storage/src/config.rs +++ b/chusql-storage/src/config.rs @@ -4,45 +4,50 @@ use serde::Deserialize; use crate::log::Level; -// 配置:优先环境变量,其次 TOML 文件,最后内置默认值(下面写的就是默认值)。 -// 每个选项的来源都记在 Loaded::origins 里,server 启动时打进日志——读了哪个文件、 -// 哪些选项走了默认回退,一眼能看出来。 +// 配置:环境变量优先于 TOML,最后回退内置默认值。 -/// 默认配置文件名(相对当前工作目录);可用 CHUSQL_CONFIG 指到别处。 +// 默认值 +/// 默认配置文件路径 pub const DEFAULT_CONFIG_PATH: &str = "chusql-storage.toml"; -/// 默认页大小(字节)。 +/// 默认池容量(页数) +pub const DEFAULT_POOL_SIZE: usize = 64; +/// 默认页大小 pub const DEFAULT_PAGE_SIZE: usize = 4096; -/// 默认 B+ 树节点最大孩子数。 +/// 默认 B+ 树 order 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 个键,分裂规则才成立。 +/// order 最小值 const MIN_BTREE_ORDER: usize = 3; -/// 生效的配置。 #[derive(Debug, Clone, PartialEq, Eq)] +// 配置 +/// 生效的配置 pub struct Config { pub page_size: usize, pub btree_order: usize, + pub pool_size: 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, + pool_size: DEFAULT_POOL_SIZE, data_dir: PathBuf::from(DEFAULT_DATA_DIR), pipe_name: DEFAULT_PIPE_NAME.to_string(), log_level: Level::Info, @@ -50,8 +55,8 @@ impl Default for Config { } } -/// 一个选项的来源。 #[derive(Debug, Clone, PartialEq, Eq)] +/// 一个选项的来源 pub enum Origin { Default, File(PathBuf), @@ -59,7 +64,7 @@ pub enum Origin { } impl Origin { - /// 日志里那一列怎么写。 + /// 来源的文案 pub fn describe(&self) -> String { match self { Origin::Default => "default".to_string(), @@ -69,20 +74,22 @@ impl Origin { } } -/// 生效配置 + 每个选项的来源。 #[derive(Debug, Clone)] +/// 配置 + 每项来源 pub struct Loaded { pub config: Config, pub config_path: Option, pub origins: Vec<(&'static str, String, Origin)>, } -/// TOML 文件里的形状:字段全部可选,没写的就当"文件没提这一项"。 #[derive(Debug, Default, Deserialize)] #[serde(deny_unknown_fields)] +// 文件形状 +/// TOML 的形状(字段都可选) struct FileConfig { page: Option, btree: Option, + buffer: Option, storage: Option, server: Option, log: Option, @@ -90,35 +97,48 @@ struct FileConfig { #[derive(Debug, Default, Deserialize)] #[serde(deny_unknown_fields)] +/// [buffer] 段 +struct FileBuffer { + pool_size: Option, +} + +#[derive(Debug, Default, Deserialize)] +#[serde(deny_unknown_fields)] +/// [page] 段 struct FilePage { size: Option, } #[derive(Debug, Default, Deserialize)] #[serde(deny_unknown_fields)] +/// [btree] 段 struct FileBtree { order: Option, } #[derive(Debug, Default, Deserialize)] #[serde(deny_unknown_fields)] +/// [storage] 段 struct FileStorage { data_dir: Option, } #[derive(Debug, Default, Deserialize)] #[serde(deny_unknown_fields)] +/// [server] 段 struct FileServer { pipe_name: Option, } #[derive(Debug, Default, Deserialize)] #[serde(deny_unknown_fields)] +/// [log] 段 struct FileLog { level: Option, } -/// 从真实环境加载:CHUSQL_CONFIG(或当前目录的默认文件)+ 环境变量覆盖。 +// 加载 +/// 按真实环境加载 pub fn load() -> Result { let path = match std::env::var("CHUSQL_CONFIG") { Ok(p) if !p.trim().is_empty() => Some(PathBuf::from(p)), @@ -137,7 +157,7 @@ pub fn load() -> Result { resolve(text.as_deref(), path, &|k| std::env::var(k).ok()) } -/// 解析 TOML 文本 + 套环境变量覆盖;测试直接调这个,不碰真实环境。 +/// 解析文本并套环境变量 pub fn resolve( text: Option<&str>, path: Option, @@ -180,6 +200,18 @@ pub fn resolve( .map_err(|_| format!("not a number: {}", s)) }, )?; + let (pool_size, pool_origin) = pick( + "CHUSQL_POOL_SIZE", + file.buffer.and_then(|b| b.pool_size), + DEFAULT_POOL_SIZE, + &file_origin, + env, + |s| { + s.trim() + .parse::() + .map_err(|_| format!("not a number: {}", s)) + }, + )?; let (data_dir, dir_origin) = pick( "CHUSQL_DATA_DIR", file.storage.and_then(|s| s.data_dir), @@ -214,10 +246,13 @@ pub fn resolve( return Err("server.pipe_name must not be empty".to_string()); } validate_layout(page_size, btree_order)?; - + if pool_size == 0 { + return Err("buffer.pool_size must be at least 1".to_string()); + } let origins = vec![ ("page.size", page_size.to_string(), page_origin), ("btree.order", btree_order.to_string(), order_origin), + ("buffer.pool_size", pool_size.to_string(), pool_origin), // 新增 ("storage.data_dir", data_dir.clone(), dir_origin), ("server.pipe_name", pipe_name.clone(), pipe_origin), ("log.level", log_level.name().to_string(), log_origin), @@ -227,6 +262,7 @@ pub fn resolve( config: Config { page_size, btree_order, + pool_size, data_dir: PathBuf::from(data_dir), pipe_name, log_level, @@ -236,7 +272,7 @@ pub fn resolve( }) } -/// 取一个选项:环境变量 → 配置文件 → 默认值,并记下它从哪来。 +/// 取一项:env -> 文件 -> 默认 fn pick( env_key: &'static str, file_value: Option, @@ -255,7 +291,8 @@ fn pick( 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!( diff --git a/chusql-storage/src/heap.rs b/chusql-storage/src/heap.rs index 1cb87eb..ebb6733 100644 --- a/chusql-storage/src/heap.rs +++ b/chusql-storage/src/heap.rs @@ -6,71 +6,68 @@ 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 字节。 +// 页内布局 +/// 页头:slot_count 占 2 字节 const HEADER_SIZE: usize = 2; -/// 一个槽位占 8 字节:offset(u32) + len(u32)。 +/// 一个槽位占 8 字节 const SLOT_SIZE: usize = 8; -/// 槽位目录起点。 +/// 槽位目录起点 const SLOT_DIR_START: usize = HEADER_SIZE; -/// 从字节里读一个小端 u16。 +/// 读一个小端 u16 fn read_u16(buf: &[u8], off: usize) -> u16 { u16::from_le_bytes([buf[off], buf[off + 1]]) } -/// 往字节里写一个小端 u16。 +/// 写一个小端 u16 fn write_u16(buf: &mut [u8], off: usize, v: u16) { buf[off..off + 2].copy_from_slice(&v.to_le_bytes()); } -/// 从字节里读一个小端 u32。 +/// 读一个小端 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。 +/// 写一个小端 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 位。 +// 索引位置 +/// (页号, 槽位号) 打包成 u64 fn pack_position(page_id: PageId, slot: u16) -> u64 { (page_id << 16) | (slot as u64) } -/// 从 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; @@ -85,7 +82,7 @@ impl HeapPage { min_off as usize } - /// 试插一行:成功返回槽位号,放不下返回 None。 + /// 试插一行,放不下返回 None pub fn insert_tuple(page: &mut Page, tuple: &[u8]) -> Option { let n = Self::slot_count(page); let free_start = Self::free_start(page); @@ -95,11 +92,9 @@ impl HeapPage { 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); @@ -108,7 +103,7 @@ impl HeapPage { Some(n) } - /// 读槽位里的行字节;槽位不存在或已删除返回 None。 + /// 读槽位里的行字节 pub fn get_tuple(page: &Page, slot: u16) -> Option> { let n = Self::slot_count(page); if slot >= n { @@ -123,46 +118,52 @@ impl HeapPage { Some(page.data[off..off + len].to_vec()) } - /// 所有槽位号(含已删除的)。 + /// 所有槽位号 pub fn iter_slots(page: &Page) -> Vec { (0..Self::slot_count(page)).collect() } } -/// 堆表:一个文件 = 若干页,顺序追加行。 +// 堆表 +/// 一个文件 = 若干页 pub struct HeapTable { file: PageFile, index: Option, } impl HeapTable { - /// 打开(或创建)一张堆表。 - pub fn open>(path: P, page_size: usize) -> io::Result { - Ok(HeapTable { - file: PageFile::open(path, page_size)?, + /// 打开表(不带索引) + pub fn open>( + path: P, + page_size: usize, + pool_size: usize, + ) -> io::Result { + Ok(HeapTable { + file: PageFile::with_options(path, page_size, pool_size)?, index: None, }) } - /// 打开带 B+ 树索引的表;索引文件不存在会自动建。 + /// 打开表 + 索引 pub fn open_indexed, Q: AsRef>( path: P, index_path: Q, page_size: usize, btree_order: usize, + pool_size: usize, ) -> io::Result { Ok(HeapTable { - file: PageFile::open(path, page_size)?, - index: Some(DiskBTree::open(index_path, page_size, btree_order)?), + file: PageFile::with_options(path, page_size, pool_size)?, + index: Some(DiskBTree::open(index_path, page_size, btree_order, pool_size)?), }) } - /// 插入一行;不更新索引。 + /// 插入一行,不更新索引 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)?; @@ -190,7 +191,7 @@ impl HeapTable { 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 { @@ -199,7 +200,7 @@ impl HeapTable { Ok(()) } - /// 按索引键取一行;没索引或键不存在返回 None。 + /// 按索引键取一行 pub fn get_by_key(&mut self, key: i64) -> io::Result> { let pos = match &mut self.index { None => return Ok(None), @@ -212,7 +213,7 @@ impl HeapTable { self.read_at(page_id, slot) } - /// 按 (页号, 槽位号) 读一行。 + /// 按位置读一行 pub fn read_at(&mut self, page_id: PageId, slot: u16) -> io::Result> { let p = self.file.read_page(page_id)?; match HeapPage::get_tuple(&p, slot) { @@ -224,7 +225,7 @@ impl HeapTable { } } - /// 全表扫描:按页顺序、按槽位顺序返回所有行。 + /// 全表扫描 pub fn scan(&mut self) -> io::Result> { let n = self.file.num_pages()?; let mut rows = Vec::new(); @@ -241,8 +242,7 @@ impl HeapTable { Ok(rows) } - /// 整表改写:清空堆表和索引,再把新行逐行插回去。 - /// 行里有整数 id 就顺手重建索引 —— 堆表位置变了,旧索引必须作废,否则会按老位置读出别的行。 + /// 整表改写并重建索引 pub fn replace_all(&mut self, rows: &[Row]) -> io::Result<()> { self.file.truncate()?; if let Some(idx) = &mut self.index { @@ -256,9 +256,27 @@ impl HeapTable { } Ok(()) } + + /// 把脏页写回 + pub fn flush(&mut self) -> io::Result<()> { + if let Some(idx) = &mut self.index { + idx.flush()?; + } + self.file.flush() + } + + // 缓冲池统计 + /// 当前缓存页数 + pub fn cached_pages(&self) -> usize { self.file.cached_pages() } + /// 命中次数 + pub fn hits(&self) -> u64 { self.file.hits() } + /// 未命中次数 + pub fn misses(&self) -> u64 { self.file.misses() } + /// 命中率 + pub fn hit_rate(&self) -> f64 { self.file.hit_rate() } } -/// 一行里的索引键:id 列是整数就用它(和 Haskell 侧"主键列叫 id"的约定一致)。 +/// 取行里的 id 作索引键 fn row_key(row: &Row) -> Option { match row.get("id") { Some(serde_json::Value::Number(n)) => n.as_i64(), diff --git a/chusql-storage/src/lib.rs b/chusql-storage/src/lib.rs index ef44d7a..1d412bd 100644 --- a/chusql-storage/src/lib.rs +++ b/chusql-storage/src/lib.rs @@ -1,4 +1,4 @@ -// 存储层库入口:对外暴露 page / protocol / heap / btree / config / log(server 与集成测试都从这里引)。 +// 存储层库入口:对外暴露下面这些模块,服务与集成测试都从这里引。 pub mod btree; pub mod config; diff --git a/chusql-storage/src/log.rs b/chusql-storage/src/log.rs index 8ed6fbb..e360443 100644 --- a/chusql-storage/src/log.rs +++ b/chusql-storage/src/log.rs @@ -3,23 +3,21 @@ 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)。 +/// 加粗红(ERROR) const BOLD_RED: &str = "\u{1b}[1m\u{1b}[31m"; -/// 加粗橙(WARN):256 色里的 208 号。 +/// 加粗橙(WARN) const BOLD_ORANGE: &str = "\u{1b}[1m\u{1b}[38;5;208m"; -/// 只加粗(DEBUG)。 +/// 只加粗(DEBUG) const BOLD: &str = "\u{1b}[1m"; -/// 日志等级;Off 表示全关。 #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +// 等级与来源 +/// 日志等级 pub enum Level { Off, Error, @@ -28,15 +26,15 @@ pub enum Level { 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", @@ -46,7 +44,7 @@ impl Channel { } impl Level { - /// 解析等级名(大小写不敏感)。 + /// 解析等级名 pub fn parse(s: &str) -> Option { match s.trim().to_ascii_lowercase().as_str() { "off" => Some(Level::Off), @@ -58,7 +56,7 @@ impl Level { } } - /// 等级名(写日志和写配置说明用)。 + /// 等级名 pub fn name(self) -> &'static str { match self { Level::Off => "off", @@ -69,7 +67,7 @@ impl Level { } } - /// 日志行里的标签,固定 5 字符宽,方便对齐。 + /// 行里的等级标签 fn tag(self) -> &'static str { match self { Level::Error => "ERROR", @@ -79,7 +77,7 @@ impl Level { } } - /// 这个等级要不要着色(INFO 不着色)。 + /// 等级的颜色 fn color(self) -> &'static str { match self { Level::Error => BOLD_RED, @@ -90,29 +88,28 @@ impl Level { } } -/// 当前等级,默认 info。 static LEVEL: AtomicU8 = AtomicU8::new(Level::Info as u8); -/// stderr 是不是终端(决定要不要着色),只问一次。 static COLOR: OnceLock = 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); @@ -121,7 +118,7 @@ pub fn write(channel: Channel, level: Level, args: fmt::Arguments) { 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, @@ -130,7 +127,7 @@ fn color_enabled() -> bool { }) } -/// 拼一行:[等级 时间] [来源] 消息。纯函数,方便测试。 +/// 拼一行(纯函数,可测) fn render(level: Level, channel: Channel, color: bool, stamp: &Stamp, msg: &str) -> String { let tag = level.tag(); let paint = level.color(); @@ -142,8 +139,9 @@ fn render(level: Level, channel: Channel, color: bool, stamp: &Stamp, msg: &str) format!("[{} {}] [{}] {}", level_text, stamp, channel.tag(), msg) } -/// 一个本地日期时间。 #[derive(Debug, Clone, Copy, PartialEq, Eq)] +// 时间 +/// 本地日期时间 pub struct Stamp { pub year: u16, pub month: u16, @@ -154,6 +152,7 @@ pub struct Stamp { pub milli: u16, } +/// 格式:年-月-日 时:分:秒.毫秒 impl fmt::Display for Stamp { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!( @@ -164,7 +163,7 @@ impl fmt::Display for Stamp { } } -/// 当前本地时间:Windows 上走 kernel32::GetLocalTime。 +/// 当前本地时间(Windows) #[cfg(windows)] fn now() -> Stamp { #[repr(C)] @@ -206,7 +205,7 @@ fn now() -> Stamp { } } -/// 当前时间:其他平台没有取本地时区的零依赖办法,退回 UTC。 +/// 当前时间(其他平台用 UTC) #[cfg(not(windows))] fn now() -> Stamp { let d = std::time::SystemTime::now() @@ -225,7 +224,7 @@ fn now() -> Stamp { } } -/// 把"距 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; @@ -239,7 +238,8 @@ pub fn civil_from_days(days: i64) -> (i64, u16, u16) { (y + i64::from(month <= 2), month, day) } -/// Windows:打开 stderr 的 VT 处理。 +// 终端 +/// 打开 Windows 的 VT 处理 #[cfg(windows)] fn enable_vt() { #[link(name = "kernel32")] @@ -249,9 +249,7 @@ fn enable_vt() { 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 { @@ -263,11 +261,12 @@ fn enable_vt() { } } -/// 非 Windows:不用做任何事。 +/// 其他平台什么都不用做 #[cfg(not(windows))] fn enable_vt() {} -/// 打一条 debug 日志(关掉时只花一次原子读)。 +// 宏 +/// debug 日志 #[macro_export] macro_rules! log_debug { (core, $($arg:tt)*) => { @@ -278,7 +277,7 @@ macro_rules! log_debug { }; } -/// 打一条 info 日志。 +/// info 日志 #[macro_export] macro_rules! log_info { (core, $($arg:tt)*) => { @@ -289,7 +288,7 @@ macro_rules! log_info { }; } -/// 打一条 warn 日志。 +/// warn 日志 #[macro_export] macro_rules! log_warn { (core, $($arg:tt)*) => { @@ -300,7 +299,7 @@ macro_rules! log_warn { }; } -/// 打一条 error 日志。 +/// error 日志 #[macro_export] macro_rules! log_error { (core, $($arg:tt)*) => { @@ -311,7 +310,7 @@ macro_rules! log_error { }; } -/// 上面四个宏的公共部分:先看等级,再看要不要真的写。内部用,别直接调。 +/// 四个宏的公共部分 #[doc(hidden)] #[macro_export] macro_rules! log_line { @@ -326,7 +325,6 @@ macro_rules! log_line { mod tests { use super::*; - /// 天数换算日期:几个已知点 + 两次闰年的 2 月 29 日。 #[test] fn civil_from_days_known_dates() { assert_eq!(civil_from_days(0), (1970, 1, 1)); @@ -336,7 +334,6 @@ mod tests { assert_eq!(civil_from_days(19_782), (2024, 2, 29)); } - /// 行格式:等级 + 本地日期时间 + 来源 + 消息,等级该粗的粗、该色的色。 #[test] fn render_has_time_channel_and_colors() { let stamp = Stamp { @@ -367,7 +364,6 @@ mod tests { ); } - /// 关掉颜色时整行必须是纯文本(重定向到文件时用)。 #[test] fn render_without_color_is_plain() { let stamp = Stamp { diff --git a/chusql-storage/src/page.rs b/chusql-storage/src/page.rs index 1e1514b..763ad3c 100644 --- a/chusql-storage/src/page.rs +++ b/chusql-storage/src/page.rs @@ -1,23 +1,26 @@ +use std::collections::{HashMap, HashSet, VecDeque}; 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 足够大,不用担心中途用完。 +// 页 +/// 页号:从 0 开始 pub type PageId = u64; -/// 一页:固定 page_size 字节,外加自己的页号。 +/// 缓冲池默认容量(页数) +pub const DEFAULT_POOL_SIZE: usize = 64; + #[derive(Debug, Clone)] +/// 一页:定长字节 + 页号 pub struct Page { pub id: PageId, pub data: Vec, } impl Page { - /// 新建一页,内容全 0。 + /// 新建一页,全 0 pub fn new(id: PageId, size: usize) -> Self { Page { id, @@ -25,46 +28,118 @@ impl Page { } } - /// 清零。复用时先清空,避免残留旧数据。 + /// 清零 pub fn zero(&mut self) { self.data.fill(0); } } -/// 页文件:负责把 Page 落到磁盘上(不做缓存,每次读写都直接 syscall;缓冲池是后面的阶段)。 #[derive(Debug)] +/// 页文件:定长页读写 + 页缓存 pub struct PageFile { file: File, page_size: usize, + capacity: usize, + cache: HashMap, + order: VecDeque, + dirty: HashSet, + hits: u64, + misses: u64, } impl PageFile { - /// 打开(或创建)一个页文件;页大小由配置传进来。 + /// 打开页文件(默认池容量) pub fn open>(path: P, page_size: usize) -> io::Result { + Self::with_options(path, page_size, DEFAULT_POOL_SIZE) + } + + /// 打开页文件并指定池容量 + pub fn with_options>( + path: P, + page_size: usize, + capacity: usize, + ) -> io::Result { let file = OpenOptions::new() .read(true) .write(true) .create(true) .truncate(false) .open(path)?; - Ok(PageFile { file, page_size }) + Ok(PageFile { + file, + page_size, + capacity, + cache: HashMap::new(), + order: VecDeque::new(), + dirty: HashSet::new(), + hits: 0, + misses: 0, + }) } - /// 这个文件的页大小。 + /// 页大小 pub fn page_size(&self) -> usize { self.page_size } - /// 读取指定页号的页;页号超出文件末尾时返回 UnexpectedEof。 - pub fn read_page(&mut self, id: PageId) -> io::Result { - 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 capacity(&self) -> usize { + self.capacity } - /// 写入一页。若页号超出当前文件末尾,文件会自动扩展。 - pub fn write_page(&mut self, page: &Page) -> io::Result<()> { + /// 当前缓存了多少页 + pub fn cached_pages(&self) -> usize { + self.cache.len() + } + + /// 命中次数 + pub fn hits(&self) -> u64 { + self.hits + } + + /// 未命中次数 + pub fn misses(&self) -> u64 { + self.misses + } + + /// 命中率 + pub fn hit_rate(&self) -> f64 { + let total = self.hits + self.misses; + if total == 0 { + 0.0 + } else { + self.hits as f64 / total as f64 + } + } + + + /// 标记为最近使用 + fn touch(&mut self, id: PageId) { + if let Some(pos) = self.order.iter().position(|&x| x == id) { + self.order.remove(pos); + } + self.order.push_back(id); + } + + /// 淘汰一个最冷的页 + fn evict_one(&mut self) -> io::Result { + while let Some(victim) = self.order.pop_front() { + if let Some(p) = self.cache.get(&victim) { + if self.dirty.contains(&victim) { + let p = p.clone(); + self.write_raw(&p)?; + self.dirty.remove(&victim); + } + self.cache.remove(&victim); + return Ok(true); + } + } + Ok(false) + } + + /// 直接写文件 + fn write_raw(&mut self, page: &Page) -> io::Result<()> { if page.data.len() != self.page_size { return Err(io::Error::new( io::ErrorKind::InvalidInput, @@ -77,12 +152,58 @@ impl PageFile { } self.file .seek(SeekFrom::Start(page.id * self.page_size as u64))?; - self.file.write_all(&page.data)?; - self.file.flush()?; + self.file.write_all(&page.data) + } + + /// 直接从文件读 + fn read_raw(&mut self, id: PageId) -> io::Result { + 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 read_page(&mut self, id: PageId) -> io::Result { + if let Some(p) = self.cache.get(&id) { + let p = p.clone(); + self.hits += 1; + self.touch(id); + return Ok(p); + } + self.misses += 1; + let p = self.read_raw(id)?; + if self.cache.len() >= self.capacity { + self.evict_one()?; + } + self.cache.insert(id, p.clone()); + self.order.push_back(id); + Ok(p) + } + + /// 写一页(走缓存) + 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 + ), + )); + } + if self.cache.len() >= self.capacity && !self.cache.contains_key(&page.id) { + self.evict_one()?; + } + self.cache.insert(page.id, page.clone()); + self.dirty.insert(page.id); + self.touch(page.id); Ok(()) } - /// 在文件末尾追加一页,返回新页的页号。 + /// 末尾追加一页 pub fn append_page(&mut self) -> io::Result { let id = self.num_pages()?; let page = Page::new(id, self.page_size); @@ -90,7 +211,7 @@ impl PageFile { Ok(page) } - /// 当前文件里有多少页;长度不是页大小整数倍,说明页大小和文件对不上。 + /// 文件里有多少页 pub fn num_pages(&mut self) -> io::Result { let len = self.file.metadata()?.len(); let size = self.page_size as u64; @@ -103,18 +224,50 @@ impl PageFile { ), )); } - Ok(len / size) + let on_disk = len / size; + let cached_max = self + .cache + .keys() + .copied() + .max() + .map(|m| m + 1) + .unwrap_or(0); + Ok(on_disk.max(cached_max)) } - /// 强制把文件缓冲刷到磁盘。写入关键路径时用。 + /// 把脏页写回磁盘 + pub fn flush(&mut self) -> io::Result<()> { + let dirty: Vec = self.dirty.iter().copied().collect(); + for id in dirty { + if let Some(p) = self.cache.get(&id) { + let p = p.clone(); + self.write_raw(&p)?; + } + } + self.file.sync_all()?; + self.dirty.clear(); + Ok(()) + } + + /// flush 之后 fsync pub fn sync(&mut self) -> io::Result<()> { - self.file.sync_all() + self.flush() } - /// 清空文件,游标归零。 + /// 清空文件 pub fn truncate(&mut self) -> io::Result<()> { + self.cache.clear(); + self.order.clear(); + self.dirty.clear(); self.file.set_len(0)?; self.file.seek(SeekFrom::Start(0))?; Ok(()) } } + + /// 退出前把脏页写回 +impl Drop for PageFile { + fn drop(&mut self) { + let _ = self.flush(); + } +} diff --git a/chusql-storage/src/protocol.rs b/chusql-storage/src/protocol.rs index 220b8ca..44e6bac 100644 --- a/chusql-storage/src/protocol.rs +++ b/chusql-storage/src/protocol.rs @@ -1,30 +1,30 @@ use serde::{Deserialize, Serialize}; use std::collections::HashMap; -// 协议定义:Haskell ↔ Rust 之间那条"行分隔 JSON"消息的类型(请求 / 响应)。 +// 协议:Haskell 与 Rust 之间的请求 / 响应类型。 -/// 一行:列名到 JSON 值的映射。 +/// 一行:列名到 JSON 值 pub type Row = HashMap; -/// 列类型;跟 Haskell 侧 TInt / TStr / TBool 对应。 #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "lowercase")] +/// 列类型(对应 Haskell 的 TInt / TStr / TBool) pub enum ColumnType { Int, Str, Bool, } -/// 一列的 schema。 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +/// 一列的 schema pub struct SchemaColumn { pub name: String, pub ty: ColumnType, } -/// 请求;method 字段做 tag。 #[derive(Debug, Serialize, Deserialize)] #[serde(tag = "method", rename_all = "snake_case")] +/// 请求:method 字段决定类型 pub enum Request { Ping, Scan { table: String }, @@ -38,16 +38,25 @@ pub enum Request { ReplaceAll { table: String, rows: Vec }, ListTables, DescribeTable { table: String }, + CreateTable { + table: String, + columns: Vec, + }, + DropTable { table: String }, } -/// 响应;status 字段做 tag。 #[derive(Debug, Serialize, Deserialize)] #[serde(tag = "status", rename_all = "lowercase")] +/// 响应:status 字段决定类型 pub enum Response { Pong, Rows { rows: Vec }, Tables { tables: Vec }, - Schema { columns: Vec }, + Schema { + columns: Vec, + #[serde(default)] + row_count: u64, + }, Ok, Error { message: String }, } diff --git a/chusql-storage/src/wal.rs b/chusql-storage/src/wal.rs index 7dbb141..5f7251e 100644 --- a/chusql-storage/src/wal.rs +++ b/chusql-storage/src/wal.rs @@ -1,15 +1,3 @@ -//! 简化版 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}; @@ -19,12 +7,13 @@ use serde::{Deserialize, Serialize}; use crate::protocol::Row; -/// 操作类型标记。 +// 预写日志:先记操作并 fsync,再改数据;重启重放残留。 + const OP_INSERT: u8 = 1; const OP_REPLACE_ALL: u8 = 2; -/// 一条 WAL 操作。 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +/// 一条待写入的操作 pub enum WalOp { Insert { table: String, @@ -37,25 +26,26 @@ pub enum WalOp { }, } -/// WAL 文件句柄。 +/// WAL 文件 pub struct Wal { path: PathBuf, } impl Wal { - /// 绑定到 WAL 文件路径;首次 append 时自动创建文件。 + // 读写 + /// 绑定 WAL 文件路径 pub fn new>(path: P) -> Self { Wal { path: path.as_ref().to_path_buf(), } } - /// 日志文件路径。 + /// 日志文件路径 pub fn path(&self) -> &Path { &self.path } - /// 清空 WAL;文件不存在则什么都不做。 + /// 清空 WAL pub fn clear(&self) -> io::Result<()> { if self.path.exists() { let f = OpenOptions::new().write(true).open(&self.path)?; @@ -65,7 +55,7 @@ impl Wal { Ok(()) } - /// 追加一条操作并 fsync。 + /// 追加一条并 fsync pub fn append(&self, op: &WalOp) -> io::Result<()> { let bytes = encode(op)?; let mut f = OpenOptions::new() @@ -77,7 +67,7 @@ impl Wal { Ok(()) } - /// 读取当前 WAL;空文件 / 不存在返回 None。 + /// 读当前 WAL;空则 None pub fn read(&self) -> io::Result> { if !self.path.exists() { return Ok(None); @@ -98,9 +88,9 @@ impl Wal { } } -/// 把一条操作编成字节。 +// 编解码 +/// 把操作编成字节 fn encode(op: &WalOp) -> io::Result> { - // 先编出 (类型, 表名, 载荷) 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)?; @@ -114,8 +104,6 @@ fn encode(op: &WalOp) -> io::Result> { 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()); @@ -124,32 +112,25 @@ fn encode(op: &WalOp) -> io::Result> { 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, 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], ]); diff --git a/chusql-storage/tests/btree_test.rs b/chusql-storage/tests/btree_test.rs index 998a916..38442f9 100644 --- a/chusql-storage/tests/btree_test.rs +++ b/chusql-storage/tests/btree_test.rs @@ -1,10 +1,10 @@ use chusql_storage::btree::DiskBTree; use chusql_storage::config::{DEFAULT_BTREE_ORDER, DEFAULT_PAGE_SIZE}; +use chusql_storage::page::DEFAULT_POOL_SIZE; -// B+ 树测试:空树查询、插入后查找、重开文件数据仍在、覆盖同一个键、升序遍历、 -// 倒序插入 500 个键、页大小 / order 可以配置、配置和文件头对不上时报错。 +// B+ 树测试:插查、重开、分裂、页大小与 order 校验。 -/// 空树的 get 返回 None,遍历返回空。 +/// 空树查不到,遍历为空 #[test] fn empty_tree_get() { let dir = tempfile::tempdir().unwrap(); @@ -12,13 +12,14 @@ fn empty_tree_get() { dir.path().join("t.idx"), DEFAULT_PAGE_SIZE, DEFAULT_BTREE_ORDER, + DEFAULT_POOL_SIZE, ) .unwrap(); assert_eq!(t.get(1).unwrap(), None); assert_eq!(t.iter_all().unwrap(), vec![]); } -/// 插 100 个键,每个都能按 key 查回来。 +/// 插 100 个键都能查回 #[test] fn insert_then_get() { let dir = tempfile::tempdir().unwrap(); @@ -26,6 +27,7 @@ fn insert_then_get() { dir.path().join("t.idx"), DEFAULT_PAGE_SIZE, DEFAULT_BTREE_ORDER, + DEFAULT_POOL_SIZE, ) .unwrap(); for i in 0..100 { @@ -36,24 +38,24 @@ fn insert_then_get() { } } -/// 关掉再打开同一个索引文件,数据还在。 +/// 重开文件数据还在 #[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(); + let mut t = DiskBTree::open(&path, DEFAULT_PAGE_SIZE, DEFAULT_BTREE_ORDER, DEFAULT_POOL_SIZE).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(); + let mut t = DiskBTree::open(&path, DEFAULT_PAGE_SIZE, DEFAULT_BTREE_ORDER, DEFAULT_POOL_SIZE).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(); @@ -61,6 +63,7 @@ fn update_existing() { dir.path().join("t.idx"), DEFAULT_PAGE_SIZE, DEFAULT_BTREE_ORDER, + DEFAULT_POOL_SIZE, ) .unwrap(); t.insert(5, 100).unwrap(); @@ -68,7 +71,7 @@ fn update_existing() { assert_eq!(t.get(5).unwrap(), Some(200)); } -/// 倒序插入后遍历,结果仍按 key 升序。 +/// 倒序插入后遍历仍升序 #[test] fn iter_all_sorted() { let dir = tempfile::tempdir().unwrap(); @@ -76,6 +79,7 @@ fn iter_all_sorted() { dir.path().join("t.idx"), DEFAULT_PAGE_SIZE, DEFAULT_BTREE_ORDER, + DEFAULT_POOL_SIZE, ) .unwrap(); for i in (0..50).rev() { @@ -88,7 +92,7 @@ fn iter_all_sorted() { } } -/// 倒序插入 500 个键(必然多次分裂),每个键都能查到。 +/// 倒序插 500 个键都能查到 #[test] fn reverse_order_insert() { let dir = tempfile::tempdir().unwrap(); @@ -96,6 +100,7 @@ fn reverse_order_insert() { dir.path().join("t.idx"), DEFAULT_PAGE_SIZE, DEFAULT_BTREE_ORDER, + DEFAULT_POOL_SIZE, ) .unwrap(); for i in (0..500).rev() { @@ -106,11 +111,11 @@ fn reverse_order_insert() { } } -/// 页大小和 order 都可以配置:512 字节的页 + 8 个孩子照样能插能查。 +/// 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(); + let mut t = DiskBTree::open(dir.path().join("t.idx"), 512, 8, DEFAULT_POOL_SIZE).unwrap(); for i in 0..200 { t.insert(i, i as u64 + 1).unwrap(); } @@ -119,32 +124,32 @@ fn custom_page_size_and_order() { } } -/// 文件头里记着页大小和 order:换配置再打开要报错,而不是读出乱七八糟的东西。 +/// 页大小或 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(); + let mut t = DiskBTree::open(&path, DEFAULT_PAGE_SIZE, DEFAULT_BTREE_ORDER, DEFAULT_POOL_SIZE).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()); + assert!(DiskBTree::open(&path, DEFAULT_PAGE_SIZE, 8, DEFAULT_POOL_SIZE).is_err()); + assert!(DiskBTree::open(&path, 8192, DEFAULT_BTREE_ORDER, DEFAULT_POOL_SIZE).is_err()); + assert!(DiskBTree::open(&path, DEFAULT_PAGE_SIZE, DEFAULT_BTREE_ORDER, DEFAULT_POOL_SIZE).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()); + assert!(DiskBTree::open(&path, DEFAULT_PAGE_SIZE, DEFAULT_BTREE_ORDER, DEFAULT_POOL_SIZE).is_err()); } -/// order 放不进一页时直接拒绝。 +/// 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()); + assert!(DiskBTree::open(dir.path().join("t.idx"), 512, 300, DEFAULT_POOL_SIZE).is_err()); } diff --git a/chusql-storage/tests/catalog_test.rs b/chusql-storage/tests/catalog_test.rs index 8faa679..b6fc394 100644 --- a/chusql-storage/tests/catalog_test.rs +++ b/chusql-storage/tests/catalog_test.rs @@ -2,6 +2,10 @@ 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 { @@ -10,6 +14,7 @@ fn row(pairs: &[(&str, serde_json::Value)]) -> Row { m } +/// 空文件得到空字典 #[test] fn empty_file_gives_empty_catalog() { let dir = tempfile::tempdir().unwrap(); @@ -18,6 +23,7 @@ fn empty_file_gives_empty_catalog() { assert_eq!(c.table_names().len(), 0); } +/// 补列之后能查到 schema #[test] fn ensure_then_describe() { let dir = tempfile::tempdir().unwrap(); @@ -39,6 +45,7 @@ fn ensure_then_describe() { assert_eq!(name_col.ty, ColumnType::Str); } +/// 再插入不会重复列 #[test] fn second_insert_does_not_duplicate_columns() { let dir = tempfile::tempdir().unwrap(); @@ -50,6 +57,7 @@ fn second_insert_does_not_duplicate_columns() { assert_eq!(c.describe("users").unwrap().columns.len(), 1); } +/// 未知表返回 None #[test] fn describe_unknown_returns_none() { let dir = tempfile::tempdir().unwrap(); diff --git a/chusql-storage/tests/config_test.rs b/chusql-storage/tests/config_test.rs index 6c30aa9..457be22 100644 --- a/chusql-storage/tests/config_test.rs +++ b/chusql-storage/tests/config_test.rs @@ -6,10 +6,9 @@ use chusql_storage::config::{ }; use chusql_storage::log::Level; -// 配置测试:没有文件时全默认、文件里写了才覆盖、环境变量优先于文件、 -// 非法值 / 未知键要报错、每个选项的来源都记得下来。 +// 配置测试:默认值、文件覆盖、环境变量优先、非法值报错。 -/// 没有配置文件、没有环境变量:全部走内置默认值。 +/// 无文件时全默认 #[test] fn defaults_without_file() { let loaded = config::resolve(None, None, &|_| None).unwrap(); @@ -23,7 +22,7 @@ fn defaults_without_file() { assert_eq!(loaded.config.log_level, Level::Info); } -/// 文件里只写了一项:这一项生效,其余回退默认(并且来源记得清清楚楚)。 +/// 只覆盖文件里写了的项 #[test] fn file_overrides_only_written_keys() { let path = PathBuf::from("t.toml"); @@ -41,15 +40,16 @@ fn file_overrides_only_written_keys() { 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 text = "[page]\nsize = 8192\n[btree]\norder = 32\n[buffer]\npool_size = 128\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.pool_size, 128); assert_eq!(loaded.config.data_dir, PathBuf::from("mydata")); assert_eq!(loaded.config.pipe_name, "mypipe"); assert_eq!(loaded.config.log_level, Level::Debug); @@ -61,7 +61,7 @@ fn file_all_keys() { ); } -/// 环境变量优先于文件。 +/// 环境变量优先于文件 #[test] fn env_beats_file() { let text = "[storage]\ndata_dir = \"from-file\"\n"; @@ -77,14 +77,14 @@ fn env_beats_file() { 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(); @@ -94,7 +94,7 @@ fn rejects_bad_page_size() { assert!(err.contains("page.size"), "got {}", err); } -/// order 太小、或者放不进一页,都要报错。 +/// order 非法要报错 #[test] fn rejects_bad_btree_order() { let err = config::resolve(Some("[btree]\norder = 2\n"), None, &|_| None).unwrap_err(); @@ -104,14 +104,14 @@ fn rejects_bad_btree_order() { 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()); @@ -119,7 +119,7 @@ fn rejects_bad_env_value() { assert!(err.contains("CHUSQL_PAGE_SIZE"), "got {}", err); } -/// 来源文案要能说清"哪来的"。 +/// 来源文案正确 #[test] fn origin_description() { assert_eq!(Origin::Default.describe(), "default"); diff --git a/chusql-storage/tests/heap_test.rs b/chusql-storage/tests/heap_test.rs index 88348d2..624113b 100644 --- a/chusql-storage/tests/heap_test.rs +++ b/chusql-storage/tests/heap_test.rs @@ -1,11 +1,11 @@ use chusql_storage::config::DEFAULT_PAGE_SIZE; use chusql_storage::heap::HeapTable; +use chusql_storage::page::DEFAULT_POOL_SIZE; 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 { @@ -14,12 +14,12 @@ fn row(pairs: &[(&str, serde_json::Value)]) -> Row { 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(); + let mut t = HeapTable::open(&path, DEFAULT_PAGE_SIZE, DEFAULT_POOL_SIZE).unwrap(); t.insert(&row(&[("id", json!(1)), ("name", json!("Alice"))])) .unwrap(); @@ -30,12 +30,12 @@ fn insert_and_scan_one_row() { assert_eq!(rows[0]["name"], json!("Alice")); } -/// 插 500 行(必然跨多页),扫描回来的行数和顺序都要对。 +/// 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(); + let mut t = HeapTable::open(&path, DEFAULT_PAGE_SIZE, DEFAULT_POOL_SIZE).unwrap(); for i in 0..500 { t.insert(&row(&[("id", json!(i))])).unwrap(); @@ -43,18 +43,17 @@ fn insert_many_rows_cross_pages() { 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 的大行也要能放进一页。 +/// 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 mut t = HeapTable::open(&path, DEFAULT_PAGE_SIZE, DEFAULT_POOL_SIZE).unwrap(); let big = "x".repeat(2000); t.insert(&row(&[("data", json!(big.clone()))])).unwrap(); @@ -64,43 +63,43 @@ fn large_row_still_fits() { 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 mut t = HeapTable::open(&path, DEFAULT_PAGE_SIZE, DEFAULT_POOL_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(); + let mut t = HeapTable::open(&path, DEFAULT_PAGE_SIZE, DEFAULT_POOL_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 mut t = HeapTable::open(&path, DEFAULT_PAGE_SIZE, DEFAULT_POOL_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 字节的页装得下小行、放不下大行,行数不变。 +/// 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(); + let mut t = HeapTable::open(&path, 512, DEFAULT_POOL_SIZE).unwrap(); for i in 0..50 { t.insert(&row(&[("id", json!(i))])).unwrap(); @@ -111,7 +110,7 @@ fn custom_page_size() { assert!(t.insert(&row(&[("data", json!(big))])).is_err()); } -/// 整表改写之后索引必须跟着重建:旧键查不到,新行按 id 能查到。 +/// 整表改写后索引重建 #[test] fn replace_all_rebuilds_index() { let dir = tempfile::tempdir().unwrap(); @@ -120,6 +119,7 @@ fn replace_all_rebuilds_index() { dir.path().join("t.idx"), DEFAULT_PAGE_SIZE, chusql_storage::config::DEFAULT_BTREE_ORDER, + DEFAULT_POOL_SIZE, ) .unwrap(); diff --git a/chusql-storage/tests/page_test.rs b/chusql-storage/tests/page_test.rs index 729ba6e..d68a086 100644 --- a/chusql-storage/tests/page_test.rs +++ b/chusql-storage/tests/page_test.rs @@ -1,10 +1,9 @@ use chusql_storage::config::DEFAULT_PAGE_SIZE; use chusql_storage::page::{Page, PageFile}; -// 页文件测试:一页的读写、页号越界报错、追加页让文件变长、多页互不干扰、覆写同一页、 -// 自定义页大小、页大小和文件对不上时报错。 +// 页文件测试:读写、追加、覆写、页大小校验与页缓存。 -/// 写入一页再读回来,内容和长度都应该一致。 +/// 写一页再读回一致 #[test] fn write_then_read_page() { let dir = tempfile::tempdir().unwrap(); @@ -23,7 +22,7 @@ fn write_then_read_page() { assert_eq!(read.data.len(), DEFAULT_PAGE_SIZE); } -/// 空文件读第 0 页应该报错。 +/// 空文件读第 0 页报错 #[test] fn read_out_of_range_returns_error() { let dir = tempfile::tempdir().unwrap(); @@ -33,7 +32,7 @@ fn read_out_of_range_returns_error() { assert!(pf.read_page(0).is_err()); } -/// 每 append 一页,num_pages 就加一。 +/// 追加让页数加一 #[test] fn append_grows_file() { let dir = tempfile::tempdir().unwrap(); @@ -50,7 +49,7 @@ fn append_grows_file() { assert_eq!(pf.num_pages().unwrap(), 3); } -/// 页 0 和页 1 的内容互不影响。 +/// 两页内容互不影响 #[test] fn two_pages_independent() { let dir = tempfile::tempdir().unwrap(); @@ -69,7 +68,7 @@ fn two_pages_independent() { assert_eq!(pf.read_page(1).unwrap().data[0], 2); } -/// 覆写同一页:读到的是新内容,页数不变。 +/// 覆写同页读到新内容 #[test] fn overwrite_page() { let dir = tempfile::tempdir().unwrap(); @@ -89,25 +88,20 @@ fn overwrite_page() { assert_eq!(pf.num_pages().unwrap(), 1); } -/// 页大小可以配置:512 字节的页照样读写,文件按 512 字节对齐。 +/// 512 字节页可用 #[test] fn custom_page_size() { let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("small.db"); - + let path = dir.path().join("t.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(); + pf.write_page(&Page::new(0, 512)).unwrap(); + pf.flush().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(); @@ -118,7 +112,7 @@ fn mismatched_file_length_errors() { assert!(pf.num_pages().is_err()); } -/// 页大小对不上的页不许写进去。 +/// 页大小不符拒绝写入 #[test] fn write_wrong_sized_page_errors() { let dir = tempfile::tempdir().unwrap(); @@ -127,3 +121,43 @@ fn write_wrong_sized_page_errors() { let mut pf = PageFile::open(&path, 512).unwrap(); assert!(pf.write_page(&Page::new(0, 1024)).is_err()); } + +/// 第二次读命中缓存 +#[test] +fn cache_hit_avoids_second_read() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("t.db"); + + { + let mut pf = PageFile::open(&path, 4096).unwrap(); + pf.write_page(&Page::new(0, 4096)).unwrap(); + pf.flush().unwrap(); + } + + let mut pf = PageFile::open(&path, 4096).unwrap(); + let _ = pf.read_page(0).unwrap(); + let _ = pf.read_page(0).unwrap(); + let _ = pf.read_page(0).unwrap(); + + assert_eq!(pf.hits(), 2); + assert_eq!(pf.misses(), 1); + assert!(pf.hit_rate() > 0.6); +} + +/// 退出时脏页写回 +#[test] +fn dirty_page_flushed_on_drop() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("t.db"); + + { + let mut pf = PageFile::open(&path, DEFAULT_PAGE_SIZE).unwrap(); + let mut p = Page::new(0, DEFAULT_PAGE_SIZE); + p.data[0] = 42; + pf.write_page(&p).unwrap(); + } + + let mut pf2 = PageFile::open(&path, DEFAULT_PAGE_SIZE).unwrap(); + let p = pf2.read_page(0).unwrap(); + assert_eq!(p.data[0], 42); +} diff --git a/chusql-storage/tests/pipe_test.rs b/chusql-storage/tests/pipe_test.rs index a65f202..23283ef 100644 --- a/chusql-storage/tests/pipe_test.rs +++ b/chusql-storage/tests/pipe_test.rs @@ -9,9 +9,9 @@ use interprocess::local_socket::{ }; use interprocess::TryClone; -// 命名管道集成测试:起 server 子进程,用唯一管道名,测试之间互不干扰。 +// 命名管道集成测试:起真 server,跑协议、索引与配置。 -/// 生成唯一管道名,避免测试之间抢占。 +/// 唯一管道名,避免测试互抢 fn unique_pipe_name() -> String { let id = std::process::id(); let ns = std::time::SystemTime::now() @@ -21,18 +21,18 @@ fn unique_pipe_name() -> String { format!("chusql-test-{}-{}", id, ns) } -/// 持有 server 子进程,Drop 时杀掉。 +/// 持有 server 子进程,退出时杀掉 struct ServerProc(Child); impl Drop for ServerProc { - /// 测试结束时杀掉子进程并回收,避免留下孤儿进程。 + /// 测试结束杀掉子进程 fn drop(&mut self) { let _ = self.0.kill(); let _ = self.0.wait(); } } -/// 启动 server 子进程,并等管道可连(最多 5 秒)。 +/// 起 server,等管道可连 fn start_server(pipe: &str) -> (ServerProc, tempfile::TempDir) { let data = tempfile::tempdir().unwrap(); @@ -57,7 +57,7 @@ fn start_server(pipe: &str) -> (ServerProc, tempfile::TempDir) { panic!("server did not start within 5s"); } -/// 连到指定管道。 +/// 连到指定管道 fn connect(pipe: &str) -> std::io::Result { let name = pipe .to_ns_name::() @@ -65,7 +65,7 @@ fn connect(pipe: &str) -> std::io::Result { LocalSocketStream::connect(name) } -/// 发一行请求,读一行响应。 +/// 发一行请求读一行响应 fn send(stream: &mut LocalSocketStream, line: &str) -> String { stream.write_all(line.as_bytes()).unwrap(); stream.write_all(b"\n").unwrap(); @@ -77,7 +77,7 @@ fn send(stream: &mut LocalSocketStream, line: &str) -> String { buf.trim().to_string() } -/// ping 应该回 pong。 +/// ping 回 pong #[test] fn ping_pong() { let pipe = unique_pipe_name(); @@ -87,7 +87,7 @@ fn ping_pong() { assert_eq!(send(&mut c, r#"{"method":"ping"}"#), r#"{"status":"pong"}"#); } -/// 插一行再 scan,能扫到刚插进去的数据。 +/// 插入后能扫到 #[test] fn insert_then_scan() { let pipe = unique_pipe_name(); @@ -104,6 +104,7 @@ fn insert_then_scan() { assert!(r2.contains("Alice"), "scan response: {}", r2); } +/// 不存在的表报错 #[test] fn scan_unknown_table_errors() { let pipe = unique_pipe_name(); @@ -115,6 +116,7 @@ fn scan_unknown_table_errors() { assert!(r.contains("unknown table"), "got {}", r); } +/// 列出已建的表 #[test] fn list_tables_returns_created_tables() { let pipe = unique_pipe_name(); @@ -129,6 +131,7 @@ fn list_tables_returns_created_tables() { assert!(r.contains("orders"), "got {}", r); } +/// 整表替换只剩新行 #[test] fn replace_all_replaces_rows() { let pipe = unique_pipe_name(); @@ -149,13 +152,13 @@ fn replace_all_replaces_rows() { assert!(!r.contains("Alice"), "old row should be gone: {}", r); } +/// 带 key 插入后能按下标查 #[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":{}}}"#, @@ -165,15 +168,14 @@ fn insert_with_key_then_lookup() { 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); } +/// 不带 key 不建索引 #[test] fn insert_without_key_does_not_index() { let pipe = unique_pipe_name(); @@ -185,12 +187,12 @@ fn insert_without_key_does_not_index() { 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) @@ -213,13 +215,11 @@ fn lookup_survives_reopen() { ); send(&mut c, &req); } - drop(srv); // 杀 server + drop(srv); } - // 等一下,让文件句柄真正释放 thread::sleep(Duration::from_millis(200)); - // 第二次:重开 server,从索引查到 let (_srv, _data) = { let child = Command::new(env!("CARGO_BIN_EXE_server")) .env("CHUSQL_PIPE", &pipe) @@ -241,7 +241,7 @@ fn lookup_survives_reopen() { assert!(r.contains("Bob"), "persist lookup: {}", r); } -/// 配置文件里的管道名和数据目录要真的生效(这里不设 CHUSQL_PIPE / CHUSQL_DATA_DIR)。 +/// 配置文件真的生效 #[test] fn config_file_is_used() { let dir = tempfile::tempdir().unwrap(); @@ -249,7 +249,6 @@ fn config_file_is_used() { 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, @@ -276,9 +275,62 @@ fn config_file_is_used() { 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" ); } + +/// 建表后能查 schema +#[test] +fn create_table_then_describe() { + let pipe = unique_pipe_name(); + let (_srv, _data) = start_server(&pipe); + let mut c = connect(&pipe).unwrap(); + + let r = send( + &mut c, + r#"{"method":"create_table","table":"ct_users","columns":[{"name":"id","ty":"int"},{"name":"name","ty":"str"}]}"#, + ); + assert!(r.contains(r#""status":"ok""#), "create: {}", r); + + let r = send(&mut c, r#"{"method":"describe_table","table":"ct_users"}"#); + assert!(r.contains("id"), "describe: {}", r); + assert!(r.contains("name"), "describe: {}", r); + assert!(r.contains(r#""ty":"int""#), "describe: {}", r); + assert!(r.contains(r#""ty":"str""#), "describe: {}", r); +} + +/// 重复建表报错 +#[test] +fn create_table_twice_errors() { + let pipe = unique_pipe_name(); + let (_srv, _data) = start_server(&pipe); + let mut c = connect(&pipe).unwrap(); + + let req = r#"{"method":"create_table","table":"dup","columns":[{"name":"id","ty":"int"}]}"#; + let r1 = send(&mut c, req); + assert!(r1.contains(r#""status":"ok""#), "first: {}", r1); + + let r2 = send(&mut c, req); + assert!(r2.contains(r#""status":"error""#), "second: {}", r2); + assert!(r2.contains("already exists"), "second: {}", r2); +} + +/// 新建空表能扫描 +#[test] +fn scan_empty_table_after_create() { + let pipe = unique_pipe_name(); + let (_srv, _data) = start_server(&pipe); + let mut c = connect(&pipe).unwrap(); + + let r = send( + &mut c, + r#"{"method":"create_table","table":"empty_t","columns":[{"name":"id","ty":"int"}]}"#, + ); + assert!(r.contains(r#""status":"ok""#), "create: {}", r); + + let r = send(&mut c, r#"{"method":"scan","table":"empty_t"}"#); + assert!(r.contains(r#""status":"rows""#), "scan: {}", r); + assert!(r.contains(r#""rows":[]"#), "scan: {}", r); +} diff --git a/chusql-storage/tests/wal_test.rs b/chusql-storage/tests/wal_test.rs index aa55a0a..d0feeb2 100644 --- a/chusql-storage/tests/wal_test.rs +++ b/chusql-storage/tests/wal_test.rs @@ -2,6 +2,9 @@ use chusql_storage::protocol::Row; use chusql_storage::wal::{Wal, WalOp}; use serde_json::json; +// WAL 测试:追加、读取、清空、截断尾巴。 + + fn row(pairs: &[(&str, serde_json::Value)]) -> Row { let mut m = Row::new(); for (k, v) in pairs { @@ -10,6 +13,7 @@ fn row(pairs: &[(&str, serde_json::Value)]) -> Row { m } +/// 写读 Insert 操作 #[test] fn append_and_read_insert() { let dir = tempfile::tempdir().unwrap(); @@ -26,6 +30,7 @@ fn append_and_read_insert() { assert_eq!(back, op); } +/// 写读 ReplaceAll 操作 #[test] fn append_and_read_replace_all() { let dir = tempfile::tempdir().unwrap(); @@ -44,6 +49,7 @@ fn append_and_read_replace_all() { assert_eq!(back, op); } +/// 文件不存在返回 None #[test] fn missing_file_returns_none() { let dir = tempfile::tempdir().unwrap(); @@ -51,6 +57,7 @@ fn missing_file_returns_none() { assert!(wal.read().unwrap().is_none()); } +/// 空文件返回 None #[test] fn empty_file_returns_none() { let dir = tempfile::tempdir().unwrap(); @@ -60,6 +67,7 @@ fn empty_file_returns_none() { assert!(wal.read().unwrap().is_none()); } +/// 清空后读不到 #[test] fn clear_truncates() { let dir = tempfile::tempdir().unwrap(); @@ -79,6 +87,7 @@ fn clear_truncates() { assert_eq!(std::fs::metadata(&path).unwrap().len(), 0); } +/// 尾巴截断被忽略 #[test] fn truncated_tail_is_ignored() { let dir = tempfile::tempdir().unwrap(); @@ -92,19 +101,15 @@ fn truncated_tail_is_ignored() { }) .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"));