完成查询优化:谓词下推、投影下推和常量折叠,配置好了benchmark

This commit is contained in:
LIm1tZz7 committed 2026-09-21 23:44:37 +08:00
1 parent cae66f0731
commit 0791fe0446
14 files changed
+771 -7

No files matched your search

+61 -6
View File
@@ -1,24 +1,39 @@
module Main where
import ChuSQL.SQLSyntax.Executor
import ChuSQL.Algebra.Op (RelOp (..))
import ChuSQL.Algebra.Optimize (optimize)
import ChuSQL.Algebra.Planner (translate)
import ChuSQL.Model
import ChuSQL.SQLSyntax.AST
import ChuSQL.SQLSyntax.Executor
import ChuSQL.SQLSyntax.Parser
users :: Table
users =
Table
{ tableName = "users"
, tableCols = ["name", "age"]
, tableCols = ["id", "name", "age"]
, tableRows =
[ [("name", VStr "Alice"), ("age", VInt 25)]
, [("name", VStr "Bob"), ("age", VInt 17)]
, [("name", VStr "Carol"), ("age", VInt 30)]
[ [("id", VInt 1), ("name", VStr "Alice"), ("age", VInt 25)]
, [("id", VInt 2), ("name", VStr "Bob"), ("age", VInt 17)]
, [("id", VInt 3), ("name", VStr "Carol"), ("age", VInt 30)]
]
}
orders :: Table
orders =
Table
{ tableName = "orders"
, tableCols = ["id", "user_id", "product"]
, tableRows =
[ [("id", VInt 1), ("user_id", VInt 1), ("product", VStr "Book")]
, [("id", VInt 2), ("user_id", VInt 2), ("product", VStr "Pen")]
, [("id", VInt 3), ("user_id", VInt 1), ("product", VStr "Cup")]
]
}
db :: Database
db = [("users", users)]
db = [("users", users), ("orders", orders)]
-- runQuery 返回 (更新后的数据库, 结果行);演示只关心结果行
rowsOf :: Either String (Database, [Row]) -> Either String [Row]
@@ -26,6 +41,7 @@ rowsOf = fmap snd
main :: IO ()
main = do
putStrLn "==================== Parser Test ====================="
let q1 = makeSelect ["name"] "users" (Just (Gt (Col "age") (LitInt 18)))
print (rowsOf (runQuery db q1))
@@ -50,3 +66,42 @@ main = do
-- 解析 SQL 文本,再交给执行引擎
let parsed = parseQuery "SELECT name FROM users WHERE age > 18 AND name = 'Alice'"
print (rowsOf (parsed >>= runQuery 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
Left err -> putStrLn err
Right query -> case translate db query of
Left err -> putStrLn err
Right plan -> do
putStrLn "--- plan before optimization ---"
putStrLn (dump 0 plan)
putStrLn "--- plan after optimization ---"
putStrLn (dump 0 (optimize db plan))
putStrLn "--- rows ---"
print (rowsOf (runQuery db query))
dump :: Int -> RelOp -> String
dump ind op = case op of
Scan a t ->
pad ++ "Scan " ++ show a ++ " " ++ show t
Filter e x ->
pad ++ "Filter " ++ show e ++ "\n" ++ dump (ind + 1) x
Project c x ->
pad ++ "Project " ++ show c ++ "\n" ++ dump (ind + 1) x
Sort s x ->
pad ++ "Sort " ++ show s ++ "\n" ++ dump (ind + 1) x
Limit n x ->
pad ++ "Limit " ++ show n ++ "\n" ++ dump (ind + 1) x
Join l r c ->
pad
++ "Join on "
++ show c
++ "\n"
++ dump (ind + 1) l
++ "\n"
++ dump (ind + 1) r
where
pad = replicate (ind * 2) ' '
+1
View File
@@ -11,6 +11,7 @@ library
hs-source-dirs: src
exposed-modules: ChuSQL.Algebra.Eval
ChuSQL.Algebra.Op
ChuSQL.Algebra.Optimize
ChuSQL.Algebra.Planner
ChuSQL.Model
ChuSQL.SQLSyntax.AST
@@ -0,0 +1,162 @@
module ChuSQL.Algebra.Optimize where
import ChuSQL.Algebra.Op (RelOp (..))
import ChuSQL.Model
import ChuSQL.SQLSyntax.AST (Expr (..))
import ChuSQL.SQLSyntax.Expr (evalExpr)
-- * 谓词下推
-- | 收集表达式里用到的列名;谓词下推判断条件归属、常量折叠判断能否求值,都要用它
colsInExpr :: Expr -> [String]
colsInExpr (Col c) = [c]
colsInExpr (Gt a b) = colsInExpr a ++ colsInExpr b
colsInExpr (Lt a b) = colsInExpr a ++ colsInExpr b
colsInExpr (Eq a b) = colsInExpr a ++ colsInExpr b
colsInExpr (And a b) = colsInExpr a ++ colsInExpr b
colsInExpr (Or a b) = colsInExpr a ++ colsInExpr b
colsInExpr _ = []
-- | 一个算子会产出哪些列;返回 ["*"] 表示"全部列,不用挑"
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 (Scan mAlias tbl) =
case lookup tbl db of
Nothing -> []
Just t -> map (prefix ++) (tableCols t)
where
prefix = maybe "" (++ ".") mAlias
-- | need 里的列是不是都能在 have 里找到;have 为 ["*"] 时一律为真
isSubsetOf :: [String] -> [String] -> Bool
isSubsetOf _ ["*"] = True
isSubsetOf need have = all (`elem` have) need
-- | 把一个用 AND 串起来的条件拆成清单:a AND b AND c 变成 [a, b, c]
splitConj :: Expr -> [Expr]
splitConj (And a b) = splitConj a ++ splitConj b
splitConj e = [e]
-- | 反过来:把条件清单用 AND 重新串成一个;空清单表示"没有条件",返回 Nothing
combineConj :: [Expr] -> Maybe Expr
combineConj [] = Nothing
combineConj (e : es) = Just (foldl And e es)
-- | 把条件按"只用到哪一边的列"分成两堆:能下推过去的 / 必须留在原地的
partitionPred :: [String] -> [Expr] -> ([Expr], [Expr])
partitionPred have = foldr pick ([], [])
where
pick e (yes, no)
| colsInExpr e `isSubsetOf` have = (e : yes, no)
| otherwise = (yes, e : no)
-- | 把一组条件依次套到算子上,一个条件一层 Filter
applyFilters :: [Expr] -> RelOp -> RelOp
applyFilters [] op = op
applyFilters (e : es) op = applyFilters es (Filter e op)
-- | Filter 压在 Join 上时的下推规则:条件按列归属拆到左右两侧,跨两边的才留在 Join 上方
pushJoin :: Database -> Expr -> RelOp -> RelOp -> Expr -> RelOp
pushJoin db p l r c =
let preds = splitConj p
lCols = relOpCols db l
rCols = relOpCols db r
(lPs, rest1) = partitionPred lCols preds
(rPs, bothPs) = partitionPred rCols rest1
l' = applyFilters lPs l
r' = applyFilters rPs r
joined = Join l' r' c
in case combineConj bothPs of
Nothing -> joined
Just p' -> Filter p' joined
-- | Filter 压在 Filter / Sort 上时的下推规则:合并上下两层 Filter,或把 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
-- * 投影下推
-- | 工具函数
needsAll :: [String] -> Bool
needsAll = elem "*"
pushProject :: Database -> [String] -> RelOp -> RelOp
pushProject db _ (Project cols x) = case pushProject db cols x of
Project cols' y | cols' == cols -> Project cols y
other -> Project cols other
pushProject db need (Filter p x) = Filter p (pushProject db (need ++ colsInExpr p) x)
pushProject db need (Sort spec x) = Sort spec (pushProject db (need ++ map fst spec) x)
pushProject db need (Limit n x) = Limit n (pushProject db need x)
pushProject db need (Join l r c)
| needsAll need = Join (pushProject db need l) (pushProject db need r) c
| otherwise =
let tot = need ++ colsInExpr c
lCols = relOpCols db l
rCols = relOpCols db r
lNeed = if needsAll lCols then ["*"] else [x | x <- tot, x `elem` lCols]
rNeed = if needsAll rCols then ["*"] else [x | x <- tot, x `elem` rCols]
in Join (pushProject db lNeed l) (pushProject db rNeed r) c
pushProject db need scan@(Scan _ _)
| needsAll need = scan
| all (`elem` need) (relOpCols db scan) = scan
| otherwise = Project need scan
-- * 常量折叠
-- | 折一个表达式:不含列就直接求值替换成字面量,含列或求值失败则原样返回
foldNode :: Expr -> Expr
foldNode e
| null (colsInExpr e) = case evalExpr e [] of
Right (VInt n) -> LitInt n
Right (VStr s) -> LitStr s
Right (VBool b) -> LitBool b
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))
foldConstants (Eq a b) = foldNode (Eq (foldConstants a) (foldConstants b))
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 db (Filter p (Join l r c)) = pushJoin db p l r c
rewriteNode db (Filter p x) = case foldConstants p of
LitBool True -> x
p' -> pushOne db (Filter p' x)
rewriteNode _ (Project ["*"] x) = x
rewriteNode db op = pushOne db op
-- | 自底向上遍历整棵算子树,每个节点套一次 rewriteNode
optimizeRelOp :: Database -> RelOp -> RelOp
optimizeRelOp db = rewriteNode db . descend
where
descend (Filter p x) = Filter p (optimizeRelOp db x)
descend (Project cols x) = Project cols (optimizeRelOp db x)
descend (Sort spec x) = Sort spec (optimizeRelOp db x)
descend (Limit n x) = Limit n (optimizeRelOp db x)
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)
@@ -25,6 +25,7 @@ data Expr
= Col String
| LitInt Int
| LitStr String
| LitBool Bool
| Gt Expr Expr
| Lt Expr Expr
| Eq Expr Expr
@@ -1,6 +1,7 @@
module ChuSQL.SQLSyntax.Executor where
import ChuSQL.Algebra.Eval (evalRelOp)
import ChuSQL.Algebra.Optimize (optimize)
import ChuSQL.Algebra.Planner (translate)
import ChuSQL.Model
import ChuSQL.SQLSyntax.AST
@@ -28,7 +29,7 @@ applyUpdates ((col, e) : rest) row = do
runQuery :: Database -> Query -> Either String (Database, [Row])
runQuery db q@Select{} = do
relOp <- translate db q
rows <- evalRelOp db relOp
rows <- evalRelOp db (optimize db relOp)
Right (db, rows)
-- 处理插入
@@ -10,6 +10,7 @@ evalExpr (Col name) row =
maybe (Left ("unknown column: " ++ name)) Right (lookup name row)
evalExpr (LitInt n) _ = Right (VInt n)
evalExpr (LitStr s) _ = Right (VStr s)
evalExpr (LitBool b) _ = Right (VBool b)
evalExpr (Gt a b) row = liftBinOp (intOp (>)) a b row
evalExpr (Lt a b) row = liftBinOp (intOp (<)) a b row
evalExpr (Eq a b) row = liftBinOp eqOp a b row
+234
View File
@@ -1,5 +1,9 @@
module Main where
import ChuSQL.Algebra.Eval (evalRelOp)
import ChuSQL.Algebra.Op (RelOp (..))
import ChuSQL.Algebra.Optimize (optimize, pushProject)
import ChuSQL.Algebra.Planner (translate)
import ChuSQL.Model
import ChuSQL.SQLSyntax.AST
import ChuSQL.SQLSyntax.Executor
@@ -813,6 +817,169 @@ main = hspec $ do
, [("u.name", VStr "Bob"), ("o.product", VStr "Pen")]
]
describe "ChuSQL.Algebra.Optimize" $ do
it "keeps single-table WHERE results identical" $ do
sameResultAsUnoptimized "SELECT name FROM users WHERE age > 18"
it "keeps JOIN with WHERE results identical" $ do
sameResultAsUnoptimized "SELECT u.name, o.product FROM users u JOIN orders o ON u.id = o.user_id WHERE u.age > 20"
it "keeps JOIN with WHERE, ORDER BY and LIMIT results identical" $ do
sameResultAsUnoptimized "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 DESC LIMIT 2"
it "keeps SELECT * results identical" $ 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
Left err -> expectationFailure err
Right q ->
case translate testDB q of
Left err -> expectationFailure err
Right relOp ->
filterPushedIntoLeft (optimize testDB relOp) `shouldBe` True
it "removes a redundant SELECT * projection" $ do
case parseQuery "SELECT * FROM users" of
Left err -> expectationFailure err
Right q ->
case translate testDB q of
Left err -> expectationFailure err
Right relOp ->
optimize testDB relOp `shouldBe` Scan Nothing "users"
it "does not push a filter through LIMIT" $ do
let cond = Gt (Col "age") (LitInt 18)
relOp = Filter cond (Limit 2 (Scan Nothing "users"))
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
Left err -> expectationFailure err
Right q ->
case translate testDB q of
Left err -> expectationFailure err
Right relOp ->
optimize testDB (optimize testDB relOp) `shouldBe` optimize testDB relOp
it "folds a constant-false predicate into LitBool False" $ do
fmap firstFilterCond (optimizedPlan "SELECT name FROM users WHERE 1 > 2")
`shouldBe` Right (Just (LitBool False))
it "removes a filter whose predicate folds to true" $ do
fmap anyFilter (optimizedPlan "SELECT name FROM users WHERE 1 = 1")
`shouldBe` Right False
it "removes a constant-true filter sitting above a join" $ do
fmap anyFilter (optimizedPlan "SELECT u.name FROM users u JOIN orders o ON u.id = o.user_id WHERE 1 = 1")
`shouldBe` Right False
it "leaves a predicate that mentions a column untouched" $ do
fmap firstFilterCond (optimizedPlan "SELECT name FROM users WHERE age > 18")
`shouldBe` Right (Just (Gt (Col "age") (LitInt 18)))
it "folds the constant part of a predicate that cannot fold as a whole" $ do
fmap firstFilterCond (optimizedPlan "SELECT name FROM users WHERE age > 18 AND 1 = 1")
`shouldBe` Right (Just (And (Gt (Col "age") (LitInt 18)) (LitBool True)))
it "keeps a constant predicate that fails to evaluate" $ do
fmap firstFilterCond (optimizedPlan "SELECT name FROM users WHERE 'abc' > 1")
`shouldBe` Right (Just (Gt (LitStr "abc") (LitInt 1)))
it "keeps constant-false results identical" $ do
sameResultAsUnoptimized "SELECT name FROM users WHERE 1 > 2"
it "keeps constant-true results identical" $ do
sameResultAsUnoptimized "SELECT name FROM users WHERE 1 = 1"
it "keeps mixed constant and column predicates identical" $ do
sameResultAsUnoptimized "SELECT name FROM users WHERE age > 18 AND 1 = 1"
it "keeps a failing constant predicate identical" $ 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)
`shouldBe` Right []
it "returns every row for a constant-true predicate" $ do
rowsOf (parseQuery "SELECT name FROM users WHERE 1 = 1" >>= runQuery testDB)
`shouldBe` Right
[ [("name", VStr "Alice")]
, [("name", VStr "Bob")]
, [("name", VStr "Carol")]
]
it "keeps the LIMIT node while pushing projection past it" $ do
let plan = Project ["name"] (Limit 2 (Scan Nothing "users"))
stripProjects (pushProject testDB ["*"] plan) `shouldBe` stripProjects plan
it "keeps the sort keys while pushing projection past a sort" $ do
let plan = Project ["name"] (Sort [("age", Desc)] (Scan Nothing "users"))
stripProjects (pushProject testDB ["*"] plan) `shouldBe` stripProjects plan
it "keeps both sides of a join" $ do
let plan =
Project
["u.name"]
( Join
(Scan (Just "u") "users")
(Scan (Just "o") "orders")
(Eq (Col "u.id") (Col "o.user_id"))
)
stripProjects (pushProject testDB ["*"] plan) `shouldBe` stripProjects plan
it "keeps nested projections in place" $ do
let plan = Project ["name"] (Project ["name", "age"] (Scan Nothing "users"))
stripProjects (pushProject testDB ["*"] plan) `shouldBe` stripProjects plan
it "keeps the whole spine of a full pipeline" $ do
let plan =
Project
["name"]
( Limit 2
( Sort
[("age", Desc)]
(Filter (Gt (Col "age") (LitInt 18)) (Scan Nothing "users"))
)
)
stripProjects (pushProject testDB ["*"] plan) `shouldBe` stripProjects plan
it "merges two predicates pushed into the same side into one filter" $ do
fmap leftSideFilters (optimizedPlan "SELECT u.name FROM users u JOIN orders o ON u.id = o.user_id WHERE u.age > 20 AND u.name = 'Alice'")
`shouldBe` Right (Just 1)
it "pushes projection into a single-table scan as one layer" $ do
optimizedPlan "SELECT name FROM users"
`shouldBe` Right (Project ["name"] (Scan Nothing "users"))
it "does not add a projection when the scan already supplies every column" $ do
optimizedPlan "SELECT id, name, age FROM users"
`shouldBe` Right (Project ["id", "name", "age"] (Scan Nothing "users"))
it "prunes the columns nobody needs on each side of a join" $ do
fmap joinSideCols (projectedPlan "SELECT u.name FROM users u JOIN orders o ON u.id = o.user_id")
`shouldBe` Right (Just ["u.name", "u.id"], Just ["o.user_id"])
it "does not prune anything when every column is taken" $ do
fmap joinSideCols (projectedPlan "SELECT * FROM users u JOIN orders o ON u.id = o.user_id")
`shouldBe` Right (Nothing, Nothing)
it "activates projection pushdown inside optimize" $ do
fmap joinSideCols (optimizedPlan "SELECT u.name FROM users u JOIN orders o ON u.id = o.user_id")
`shouldBe` Right (Just ["u.name", "u.id"], Just ["o.user_id"])
it "keeps projection pushdown idempotent" $ do
case projectedPlan "SELECT u.name FROM users u JOIN orders o ON u.id = o.user_id" of
Left err -> expectationFailure err
Right plan -> pushProject testDB ["*"] plan `shouldBe` plan
it "keeps a join without WHERE results identical" $ do
sameResultAsUnoptimized "SELECT u.name, o.product FROM users u JOIN orders o ON u.id = o.user_id"
it "keeps projection with ORDER BY and LIMIT results identical" $ do
sameResultAsUnoptimized "SELECT u.name FROM users u JOIN orders o ON u.id = o.user_id ORDER BY o.product DESC LIMIT 2"
-- ============================================================
-- Helpers
-- ============================================================
@@ -822,3 +989,70 @@ isLeft (Right _) = False
rowsOf :: Either String (Database, [Row]) -> Either String [Row]
rowsOf = fmap snd
sameResultAsUnoptimized :: String -> Expectation
sameResultAsUnoptimized sql =
case parseQuery sql of
Left err -> expectationFailure err
Right q ->
case translate testDB q of
Left err -> expectationFailure err
Right relOp ->
evalRelOp testDB (optimize testDB relOp) `shouldBe` evalRelOp testDB relOp
filterPushedIntoLeft :: RelOp -> Bool
filterPushedIntoLeft (Project _ (Join (Filter _ _) _ _)) = True
filterPushedIntoLeft _ = False
optimizedPlan :: String -> Either String RelOp
optimizedPlan sql = do
q <- parseQuery sql
optimize testDB <$> translate testDB q
firstFilterCond :: RelOp -> Maybe Expr
firstFilterCond (Filter p _) = Just p
firstFilterCond (Project _ x) = firstFilterCond x
firstFilterCond (Sort _ x) = firstFilterCond x
firstFilterCond (Limit _ x) = firstFilterCond x
firstFilterCond (Join l r _) = case firstFilterCond l of
Just p -> Just p
Nothing -> firstFilterCond r
firstFilterCond (Scan _ _) = Nothing
anyFilter :: RelOp -> Bool
anyFilter (Filter _ _) = True
anyFilter (Project _ x) = anyFilter x
anyFilter (Sort _ x) = anyFilter x
anyFilter (Limit _ x) = anyFilter x
anyFilter (Join l r _) = anyFilter l || anyFilter r
anyFilter (Scan _ _) = False
stripProjects :: RelOp -> RelOp
stripProjects (Project _ x) = stripProjects x
stripProjects (Filter p x) = Filter p (stripProjects x)
stripProjects (Sort spec x) = Sort spec (stripProjects x)
stripProjects (Limit n x) = Limit n (stripProjects x)
stripProjects (Join l r c) = Join (stripProjects l) (stripProjects r) c
stripProjects (Scan a t) = Scan a t
projectedPlan :: String -> Either String RelOp
projectedPlan sql = do
q <- parseQuery sql
plan <- translate testDB q
Right (pushProject testDB ["*"] plan)
sideCols :: RelOp -> Maybe [String]
sideCols (Project cols _) = Just cols
sideCols _ = Nothing
joinSideCols :: RelOp -> (Maybe [String], Maybe [String])
joinSideCols (Project _ (Join l r _)) = (sideCols l, sideCols r)
joinSideCols _ = (Nothing, Nothing)
countFilters :: RelOp -> Int
countFilters (Filter _ x) = 1 + countFilters x
countFilters _ = 0
leftSideFilters :: RelOp -> Maybe Int
leftSideFilters (Project _ (Join l _ _)) = Just (countFilters l)
leftSideFilters _ = Nothing