2524 lines
131 KiB
Haskell
2524 lines
131 KiB
Haskell
{-# LANGUAGE CPP #-}
|
||
|
||
module Main where
|
||
|
||
import ChuSQL.Core.Engine.Algebra.Eval (evalRelOp, evalRelOpM)
|
||
import ChuSQL.Core.Engine.Algebra.Expr (colsInExpr, evalExpr)
|
||
import ChuSQL.Core.Engine.Algebra.Op (RelOp (..), renderPlan)
|
||
import ChuSQL.Core.Engine.Algebra.Optimize (optimize, pushProject)
|
||
import ChuSQL.Core.Engine.Algebra.Planner (translate)
|
||
import ChuSQL.Core.Engine.Algebra.Sort (sortRows)
|
||
import ChuSQL.Core.Engine.Builtin
|
||
import ChuSQL.Core.Engine.Error
|
||
import ChuSQL.Core.Engine
|
||
import ChuSQL.Core.Engine.Parallel (poolRun, workerPool)
|
||
import ChuSQL.Core.Model
|
||
import ChuSQL.Core.Engine.Semantic (prepare)
|
||
import ChuSQL.Core.Engine.Storage (IndexResult (..), MonadStorage (..))
|
||
import ChuSQL.Core.Engine.Storage.IPC (IPCStorage (runIPCStorage), closeConnection, doListTables, getDatabaseName, localStorageLink, setDatabaseName, setStorageLink)
|
||
import ChuSQL.Core.Engine.Syntax.AST
|
||
import ChuSQL.Core.Engine.Syntax.Parser
|
||
import Control.Concurrent (threadDelay)
|
||
import Control.Concurrent.MVar (MVar, modifyMVar_, newMVar, readMVar)
|
||
import Control.Exception (ErrorCall, IOException, finally, try)
|
||
import Data.Hashable (hash)
|
||
import Data.List (isInfixOf, sortOn)
|
||
import Data.Unique (hashUnique, newUnique)
|
||
import System.CPUTime (getCPUTime)
|
||
import System.Directory (createDirectoryIfMissing, getTemporaryDirectory, removePathForcibly)
|
||
import System.FilePath ((</>))
|
||
import System.Timeout (timeout)
|
||
import Test.Hspec
|
||
|
||
-- 引擎层的 hspec 测试:语法、执行、优化器与存储链路行为。
|
||
|
||
-- | 测试表 users:id / name / age
|
||
users :: Table
|
||
users =
|
||
Table
|
||
{ tableName = "users"
|
||
, tableCols = [("id", TInt), ("name", TStr), ("age", TInt)]
|
||
, tableRows =
|
||
[ [("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)]
|
||
]
|
||
, tableMeta = Nothing
|
||
}
|
||
|
||
-- | 测试表 orders:id/user_id/product
|
||
orders :: Table
|
||
orders =
|
||
Table
|
||
{ tableName = "orders"
|
||
, tableCols = [("id", TInt), ("user_id", TInt), ("product", TStr)]
|
||
, 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")]
|
||
]
|
||
, tableMeta = Nothing
|
||
}
|
||
|
||
-- | 两个测试表组成的默认库
|
||
testDB :: Database
|
||
testDB = [("users", users), ("orders", orders)]
|
||
|
||
-- | 连接链测试表 a:6 行,id 各不相同
|
||
chainA :: Table
|
||
chainA =
|
||
Table
|
||
{ tableName = "a"
|
||
, tableCols = [("id", TInt), ("name", TStr)]
|
||
, tableRows = [[("id", VInt n), ("name", VStr ("a" ++ show n))] | n <- [1 .. 6]]
|
||
, tableMeta = Just (TableMeta 6 [("id", 6, False)] ["id"])
|
||
}
|
||
|
||
-- | 连接链测试表 b:4 行,a_id 各不相同
|
||
chainB :: Table
|
||
chainB =
|
||
Table
|
||
{ tableName = "b"
|
||
, tableCols = [("id", TInt), ("a_id", TInt)]
|
||
, tableRows = [[("id", VInt n), ("a_id", VInt n)] | n <- [1 .. 4]]
|
||
, tableMeta = Just (TableMeta 4 [("id", 4, False), ("a_id", 4, False)] ["id"])
|
||
}
|
||
|
||
-- | 连接链测试表 c:2 行,b_id 各不相同
|
||
chainC :: Table
|
||
chainC =
|
||
Table
|
||
{ tableName = "c"
|
||
, tableCols = [("id", TInt), ("b_id", TInt)]
|
||
, tableRows = [[("id", VInt n), ("b_id", VInt n)] | n <- [1, 2]]
|
||
, tableMeta = Just (TableMeta 2 [("id", 2, False), ("b_id", 2, False)] ["id"])
|
||
}
|
||
|
||
-- | 三张表组成的连接链测试库
|
||
chainDB :: Database
|
||
chainDB = [("a", chainA), ("b", chainB), ("c", chainC)]
|
||
|
||
-- | 同一个连接链测试库,但没有统计
|
||
chainDBWithoutStats :: Database
|
||
chainDBWithoutStats = [(n, t { tableMeta = Nothing }) | (n, t) <- chainDB]
|
||
|
||
-- | 跑引擎层全部 hspec 用例
|
||
main :: IO ()
|
||
main = hspec $ do
|
||
describe "ChuSQL.Core.Engine.Syntax.Parser" $ do
|
||
it "resolves table-qualified columns inside database-qualified tables" $ do
|
||
let db = [("sales.users", users)]
|
||
rowsOf (parseStatement "SELECT users.name FROM sales.users WHERE users.id = 1" >>= runStatement db)
|
||
`shouldBe` Right [[("name", VStr "Alice")]]
|
||
rowsOf (parseStatement "SELECT sales.users.name FROM sales.users WHERE sales.users.id = 1" >>= runStatement db)
|
||
`shouldBe` Right [[("name", VStr "Alice")]]
|
||
|
||
it "parses database lifecycle statements" $ do
|
||
map (fmap show . parseStatement) ["CREATE DATABASE sales", "DROP DATABASE sales", "USE sales", "SHOW DATABASES"]
|
||
`shouldBe` map Right ["CreateDatabase \"sales\"", "DropDatabase \"sales\"", "UseDatabase \"sales\"", "ShowDatabases"]
|
||
|
||
it "parses qualified tables for reads and writes" $ do
|
||
parseStatement "SELECT * FROM sales.items" `shouldBe` Right (makeSelect ["*"] "sales.items" Nothing)
|
||
parseStatement "INSERT INTO sales.items (id) VALUES (1)" `shouldBe` Right (Insert "sales.items" ["id"] [[LitInt 1]])
|
||
parseStatement "CREATE TABLE sales.items (id int)" `shouldBe` Right (CreateTable "sales.items" [("id", TInt)])
|
||
parseStatement "DROP TABLE sales.items" `shouldBe` Right (DropTable "sales.items")
|
||
|
||
it "parses multiple columns" $ do
|
||
parseStatement "SELECT name, age FROM users"
|
||
`shouldBe` Right (makeSelect ["name", "age"] "users" Nothing)
|
||
|
||
it "parses WHERE with >" $ do
|
||
parseStatement "SELECT name FROM users WHERE age > 18"
|
||
`shouldBe` Right
|
||
( makeSelect
|
||
["name"]
|
||
"users"
|
||
(Just (Gt (Col "age") (LitInt 18)))
|
||
)
|
||
|
||
it "parses AND" $ do
|
||
parseStatement "SELECT name FROM users WHERE age > 18 AND age < 30"
|
||
`shouldBe` Right
|
||
( makeSelect
|
||
["name"]
|
||
"users"
|
||
( Just
|
||
( And
|
||
(Gt (Col "age") (LitInt 18))
|
||
(Lt (Col "age") (LitInt 30))
|
||
)
|
||
)
|
||
)
|
||
|
||
it "parses OR" $ do
|
||
parseStatement "SELECT name FROM users WHERE age < 18 OR age > 60"
|
||
`shouldBe` Right
|
||
( makeSelect
|
||
["name"]
|
||
"users"
|
||
( Just
|
||
( Or
|
||
(Lt (Col "age") (LitInt 18))
|
||
(Gt (Col "age") (LitInt 60))
|
||
)
|
||
)
|
||
)
|
||
|
||
it "is case-insensitive for keywords" $ do
|
||
parseStatement "select name from users where age > 18"
|
||
`shouldBe` Right
|
||
( makeSelect
|
||
["name"]
|
||
"users"
|
||
(Just (Gt (Col "age") (LitInt 18)))
|
||
)
|
||
|
||
it "tolerates extra whitespace" $ do
|
||
parseStatement "SELECT name FROM users"
|
||
`shouldBe` Right (makeSelect ["name"] "users" Nothing)
|
||
|
||
it "uses standard SQL escaping: '' is one quote" $ do
|
||
parseStatement "SELECT name FROM users WHERE name = 'It''s ok'"
|
||
`shouldBe` Right
|
||
( makeSelect
|
||
["name"]
|
||
"users"
|
||
(Just (Eq (Col "name") (LitStr "It's ok")))
|
||
)
|
||
|
||
it "treats a backslash as an ordinary character" $ do
|
||
parseStatement "SELECT name FROM users WHERE name = 'a\\b'"
|
||
`shouldBe` Right
|
||
( makeSelect
|
||
["name"]
|
||
"users"
|
||
(Just (Eq (Col "name") (LitStr "a\\b")))
|
||
)
|
||
|
||
it "parses an empty string literal" $ do
|
||
parseStatement "SELECT name FROM users WHERE name = ''"
|
||
`shouldBe` Right
|
||
( makeSelect
|
||
["name"]
|
||
"users"
|
||
(Just (Eq (Col "name") (LitStr "")))
|
||
)
|
||
|
||
it "parses a string that is a single quote" $ do
|
||
parseStatement "SELECT name FROM users WHERE name = ''''"
|
||
`shouldBe` Right
|
||
( makeSelect
|
||
["name"]
|
||
"users"
|
||
(Just (Eq (Col "name") (LitStr "'")))
|
||
)
|
||
|
||
it "rejects an unterminated string literal" $ do
|
||
parseStatement "SELECT name FROM users WHERE name = 'oops"
|
||
`shouldSatisfy` isLeft
|
||
|
||
it "honours parentheses and AND/OR precedence" $ do
|
||
parseStatement "SELECT name FROM users WHERE (age > 18 OR age < 5) AND name = 'Bob'"
|
||
`shouldBe` Right
|
||
( makeSelect
|
||
["name"]
|
||
"users"
|
||
( Just
|
||
( And
|
||
( Or
|
||
(Gt (Col "age") (LitInt 18))
|
||
(Lt (Col "age") (LitInt 5))
|
||
)
|
||
(Eq (Col "name") (LitStr "Bob"))
|
||
)
|
||
)
|
||
)
|
||
|
||
it "rejects trailing junk after a complete query" $ do
|
||
parseStatement "SELECT name FROM users extra junk"
|
||
`shouldSatisfy` isLeft
|
||
|
||
it "returns Left on missing table name" $ do
|
||
parseStatement "SELECT name FROM"
|
||
`shouldSatisfy` isLeft
|
||
|
||
describe "ChuSQL.Core.Engine.Syntax.Parser (JOIN)" $ do
|
||
it "parses a simple JOIN without aliases" $ do
|
||
parseStatement "SELECT name FROM users JOIN orders ON id = user_id"
|
||
`shouldBe` Right
|
||
( Select
|
||
{ selectCols = ["name"]
|
||
, selectFrom =
|
||
FromJoin
|
||
InnerJoin
|
||
(FromTable Nothing "users")
|
||
Nothing
|
||
"orders"
|
||
(Eq (Col "id") (Col "user_id"))
|
||
, selectWhere = Nothing
|
||
, selectGroupBy = []
|
||
, selectOrderBy = []
|
||
, selectLimit = Nothing
|
||
}
|
||
)
|
||
|
||
it "parses JOIN with aliases" $ do
|
||
parseStatement "SELECT u.name FROM users u JOIN orders o ON u.id = o.user_id"
|
||
`shouldBe` Right
|
||
( Select
|
||
{ selectCols = ["u.name"]
|
||
, selectFrom =
|
||
FromJoin
|
||
InnerJoin
|
||
(FromTable (Just "u") "users")
|
||
(Just "o")
|
||
"orders"
|
||
(Eq (Col "u.id") (Col "o.user_id"))
|
||
, selectWhere = Nothing
|
||
, selectGroupBy = []
|
||
, selectOrderBy = []
|
||
, selectLimit = Nothing
|
||
}
|
||
)
|
||
|
||
it "parses LEFT JOIN" $ do
|
||
parseStatement "SELECT u.name FROM users u LEFT JOIN orders o ON u.id = o.user_id"
|
||
`shouldBe` Right
|
||
( Select
|
||
{ selectCols = ["u.name"]
|
||
, selectFrom =
|
||
FromJoin
|
||
LeftJoin
|
||
(FromTable (Just "u") "users")
|
||
(Just "o")
|
||
"orders"
|
||
(Eq (Col "u.id") (Col "o.user_id"))
|
||
, selectWhere = Nothing
|
||
, selectGroupBy = []
|
||
, selectOrderBy = []
|
||
, selectLimit = Nothing
|
||
}
|
||
)
|
||
|
||
it "parses LEFT OUTER JOIN the same as LEFT JOIN" $ do
|
||
fmap selectFrom (parseStatement "SELECT u.name FROM users u LEFT OUTER JOIN orders o ON u.id = o.user_id")
|
||
`shouldBe` Right
|
||
( FromJoin
|
||
LeftJoin
|
||
(FromTable (Just "u") "users")
|
||
(Just "o")
|
||
"orders"
|
||
(Eq (Col "u.id") (Col "o.user_id"))
|
||
)
|
||
|
||
describe "ChuSQL.Core.Engine (SELECT)" $ do
|
||
-- sql:在默认库上跑 SQL 取结果行
|
||
let sql input = parseStatement input >>= rowsOf . runStatement testDB
|
||
-- values:只取结果里的值
|
||
values input = fmap (map (map snd)) (sql input)
|
||
it "skips line comments between tokens and at EOF" $ do
|
||
sql "-- start\nSELECT name -- list\nFROM users -- end" `shouldBe` sql "SELECT name FROM users"
|
||
it "skips block comments in lists and WHERE" $ do
|
||
sql "/* start */ SELECT name/* a */, age FROM users WHERE age/* b */>18/* end */"
|
||
`shouldBe` sql "SELECT name, age FROM users WHERE age > 18"
|
||
it "preserves comment markers in strings" $ do
|
||
values "SELECT 'a--b', '/*x*/'" `shouldBe` Right [[VStr "a--b", VStr "/*x*/"]]
|
||
it "reports the position of an unterminated block comment" $ do
|
||
parseStatement "SELECT name FROM users /*"
|
||
`shouldSatisfy` either (\err -> "<query>:1:" `isInfixOf` err && "end of input" `isInfixOf` err) (const False)
|
||
it "evaluates multiplication before addition and comparison" $ do
|
||
values "SELECT 1 + 2 * 3 = 7" `shouldBe` Right [[VBool True]]
|
||
it "honours parentheses and unary minus" $ do
|
||
values "SELECT -(1 + 2) * 3, 5 - -2" `shouldBe` Right [[VInt (-9), VInt 7]]
|
||
it "evaluates arithmetic projections and predicates" $ do
|
||
values "SELECT age + 1 FROM users WHERE age + 1 > 25" `shouldBe` Right [[VInt 26], [VInt 31]]
|
||
it "uses left associative subtraction and integer division" $ do
|
||
values "SELECT 10 - 3 - 2, -7 / 2, 12 / 3 * 2" `shouldBe` Right [[VInt 5, VInt (-3), VInt 8]]
|
||
it "returns division by zero as Left" $ do
|
||
sql "SELECT 1 / 0" `shouldSatisfy` either (isInfixOf "division by zero") (const False)
|
||
it "rejects arithmetic type errors in SELECT" $ do
|
||
sql "SELECT name + 1 FROM users" `shouldSatisfy` either (isInfixOf "SELECT: operator needs TInt") (const False)
|
||
it "checks arithmetic columns even for empty tables" $ do
|
||
(parseStatement "SELECT age + missing FROM users" >>= rowsOf . runStatement emptyDB)
|
||
`shouldSatisfy` either (isInfixOf "unknown column") (const False)
|
||
it "accepts default table prefixes with identical results" $ do
|
||
sql "SELECT users.name FROM users WHERE users.id = 1" `shouldBe` sql "SELECT name FROM users WHERE id = 1"
|
||
it "resolves default prefixes in JOIN" $ do
|
||
values "SELECT users.name, orders.product FROM users JOIN orders ON users.id = orders.user_id"
|
||
`shouldBe` Right [[VStr "Alice", VStr "Book"], [VStr "Alice", VStr "Cup"], [VStr "Bob", VStr "Pen"]]
|
||
it "rejects ambiguous bare JOIN columns" $ do
|
||
sql "SELECT id FROM users JOIN orders ON users.id = orders.user_id"
|
||
`shouldSatisfy` either (isInfixOf "ambiguous column") (const False)
|
||
it "accepts AS and keeps explicit aliases" $ do
|
||
sql "SELECT u.name FROM users AS u WHERE u.id = 1" `shouldBe` Right [[("u.name", VStr "Alice")]]
|
||
it "resolves bare columns in aliased queries" $ do
|
||
sql "SELECT name FROM users u WHERE id = 1" `shouldBe` sql "SELECT u.name FROM users u WHERE u.id = 1"
|
||
it "sorts by default qualified columns" $ do
|
||
sql "SELECT name FROM users ORDER BY users.age" `shouldBe` sql "SELECT name FROM users ORDER BY age"
|
||
it "finds every column inside arithmetic" $ do
|
||
colsInExpr (Add (Neg (Col "a")) (Sub (Mul (Col "b") (Col "c")) (Div (Col "d") (Col "e"))))
|
||
`shouldBe` ["a", "b", "c", "d", "e"]
|
||
it "returns runtime arithmetic type errors" $ do
|
||
evalExpr (Add (LitStr "x") (LitInt 1)) [] `shouldBe` Left "type error: expected two numbers"
|
||
evalExpr (Div (LitInt 1) (LitBool False)) [] `shouldBe` Left "type error: expected two numbers"
|
||
evalExpr (Neg (LitStr "x")) [] `shouldBe` Left "type error: expected a number"
|
||
it "returns division overflow without throwing" $ do
|
||
evalExpr (Div (LitInt minBound) (LitInt (-1))) [] `shouldBe` Left "integer division overflow"
|
||
it "folds arithmetic constants in projections" $ do
|
||
let plan = Compute [("answer", Add (LitInt 1) (Mul (LitInt 2) (LitInt 3)))] Unit
|
||
optimize testDB plan `shouldBe` Compute [("answer", LitInt 7)] Unit
|
||
it "preserves division errors during folding" $ do
|
||
let plan = Compute [("bad", Div (LitInt 1) (Sub (LitInt 2) (LitInt 2)))] Unit
|
||
evalRelOp testDB (optimize testDB plan) `shouldBe` Left "division by zero"
|
||
it "preserves arithmetic JOIN results and order during optimization" $ do
|
||
let plan = do
|
||
q <- parseStatement "SELECT u.name, o.product, u.age + 1 FROM users u JOIN orders o ON u.id = o.user_id WHERE u.age + 1 > 20"
|
||
prepare testDB q >>= translate
|
||
case plan of
|
||
Left err -> expectationFailure err
|
||
Right p -> do
|
||
let expected = Right [[("u.name", VStr "Alice"), ("o.product", VStr "Book"), ("u.age + 1", VInt 26)], [("u.name", VStr "Alice"), ("o.product", VStr "Cup"), ("u.age + 1", VInt 26)]]
|
||
evalRelOp testDB p `shouldBe` expected
|
||
evalRelOp testDB (optimize testDB p) `shouldBe` expected
|
||
it "rejects ambiguous WHERE and ORDER BY columns" $ do
|
||
sql "SELECT users.name FROM users JOIN orders ON users.id = orders.user_id WHERE id = 1"
|
||
`shouldSatisfy` either (isInfixOf "ambiguous column") (const False)
|
||
sql "SELECT users.name FROM users JOIN orders ON users.id = orders.user_id ORDER BY id"
|
||
`shouldSatisfy` either (isInfixOf "ambiguous column") (const False)
|
||
it "does not accept the table prefix after an explicit alias" $ do
|
||
sql "SELECT users.name FROM users u" `shouldSatisfy` either (isInfixOf "unknown column") (const False)
|
||
it "rejects duplicate qualifiers instead of selecting the first column" $ do
|
||
sql "SELECT users.name FROM users JOIN users ON users.id = users.id"
|
||
`shouldSatisfy` either (isInfixOf "ambiguous column") (const False)
|
||
it "keeps default-qualified no-index lookup results" $ do
|
||
sql "SELECT users.name FROM users WHERE users.age = 25" `shouldBe` Right [[("name", VStr "Alice")]]
|
||
it "updates columns with arithmetic" $ do
|
||
case parseStatement "UPDATE users SET age = age + 1 WHERE id = 1" >>= runStatement testDB of
|
||
Left err -> expectationFailure err
|
||
Right (db, _) -> rowsOf (runStatement db (makeSelect ["age"] "users" (Just (Eq (Col "id") (LitInt 1))))) `shouldBe` Right [[("age", VInt 26)]]
|
||
it "inserts arithmetic and negative values" $ do
|
||
case parseStatement "INSERT INTO users (id, name, age) VALUES (4, 'a--b/*c*/', -2 + 1)" >>= runStatement testDB of
|
||
Left err -> expectationFailure err
|
||
Right (db, _) -> rowsOf (runStatement db (makeSelect ["age"] "users" (Just (Eq (Col "id") (LitInt 4))))) `shouldBe` Right [[("age", VInt (-1))]]
|
||
it "rejects arithmetic types in empty-table predicates" $ do
|
||
(parseStatement "SELECT name FROM users WHERE name + 1 > 90" >>= rowsOf . runStatement emptyDB)
|
||
`shouldSatisfy` either (isInfixOf "WHERE: operator needs TInt") (const False)
|
||
it "rejects wildcard without FROM" $ do
|
||
sql "SELECT *" `shouldBe` Left "SELECT: * requires FROM"
|
||
it "applies WHERE and LIMIT to the unit row" $ do
|
||
values "SELECT 1 + 2 WHERE FALSE" `shouldBe` Right []
|
||
values "SELECT 1 + 2 LIMIT 0" `shouldBe` Right []
|
||
it "does not move division errors ahead of an empty JOIN" $ do
|
||
let db = [(n, if n == "orders" then t{tableRows = []} else t) | (n, t) <- testDB]
|
||
plan = parseStatement "SELECT u.name FROM users u JOIN orders o ON u.id = o.user_id WHERE u.age / 0 > 1" >>= prepare db >>= translate
|
||
case plan of
|
||
Left err -> expectationFailure err
|
||
Right p -> do
|
||
evalRelOp db p `shouldBe` Right []
|
||
evalRelOp db (optimize db p) `shouldBe` Right []
|
||
it "does not hide JOIN division errors with a pushed predicate" $ do
|
||
let plan = parseStatement "SELECT u.name FROM users u JOIN orders o ON u.id / 0 = o.user_id WHERE u.age > 100" >>= prepare testDB >>= translate
|
||
case plan of
|
||
Left err -> expectationFailure err
|
||
Right p -> do
|
||
evalRelOp testDB p `shouldBe` Left "division by zero"
|
||
evalRelOp testDB (optimize testDB p) `shouldBe` Left "division by zero"
|
||
it "keeps default JOIN wildcard headers unambiguous" $ do
|
||
fmap (map (map fst)) (sql "SELECT * FROM users JOIN orders ON users.id = orders.user_id LIMIT 1")
|
||
`shouldBe` Right [["users.id", "users.name", "users.age", "orders.id", "orders.user_id", "orders.product"]]
|
||
it "returns all rows without WHERE" $ do
|
||
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)]
|
||
, [("id", VInt 3), ("name", VStr "Carol"), ("age", VInt 30)]
|
||
]
|
||
|
||
it "filters with WHERE" $ do
|
||
rowsOf
|
||
( runStatement
|
||
testDB
|
||
( makeSelect
|
||
["name"]
|
||
"users"
|
||
(Just (Gt (Col "age") (LitInt 18)))
|
||
)
|
||
)
|
||
`shouldBe` Right
|
||
[ [("name", VStr "Alice")]
|
||
, [("name", VStr "Carol")]
|
||
]
|
||
|
||
it "projects only the requested columns" $ do
|
||
rowsOf (runStatement testDB (makeSelect ["name"] "users" Nothing))
|
||
`shouldBe` Right
|
||
[ [("name", VStr "Alice")]
|
||
, [("name", VStr "Bob")]
|
||
, [("name", VStr "Carol")]
|
||
]
|
||
|
||
it "returns Left for an unknown table" $ do
|
||
runStatement testDB (makeSelect ["name"] "nonexistent" Nothing)
|
||
`shouldSatisfy` isLeft
|
||
|
||
it "returns Left for an unknown projected column" $ do
|
||
runStatement testDB (makeSelect ["nope"] "users" Nothing)
|
||
`shouldSatisfy` isLeft
|
||
|
||
describe "ChuSQL.Core.Engine (INSERT)" $ do
|
||
it "parses a simple INSERT" $ do
|
||
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 runStatement testDB (Insert "users" ["name", "age"] [[LitStr "Dave", LitInt 22]]) of
|
||
Left err -> expectationFailure err
|
||
Right (db', _) -> do
|
||
rowsOf (runStatement db' (makeSelect ["name", "age"] "users" Nothing))
|
||
`shouldBe` Right
|
||
[ [("name", VStr "Alice"), ("age", VInt 25)]
|
||
, [("name", VStr "Bob"), ("age", VInt 17)]
|
||
, [("name", VStr "Carol"), ("age", VInt 30)]
|
||
, [("name", VStr "Dave"), ("age", VInt 22)]
|
||
]
|
||
|
||
it "does not modify the original database" $ do
|
||
case runStatement testDB (Insert "users" ["name", "age"] [[LitStr "Dave", LitInt 22]]) of
|
||
Left err -> expectationFailure err
|
||
Right (_, _) -> do
|
||
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
|
||
runStatement testDB (Insert "users" ["name", "age"] [[LitStr "Dave"]])
|
||
`shouldSatisfy` isLeft
|
||
|
||
it "returns Left when a value has the wrong type for comparison" $ do
|
||
runStatement testDB (Insert "users" ["name"] [[Col "other"]])
|
||
`shouldSatisfy` isLeft
|
||
it "writes id column to index and queries it back" $ do
|
||
case parseStatement "INSERT INTO users (id, name, age) VALUES (42, 'Zoe', 20)" >>= runStatement testDB of
|
||
Left err -> expectationFailure err
|
||
Right (db', _) -> do
|
||
rowsOf (parseStatement "SELECT name FROM users WHERE id = 42" >>= runStatement db')
|
||
`shouldBe` Right [[("name", VStr "Zoe")]]
|
||
|
||
it "inserts all rows of a multi-row INSERT" $ do
|
||
case parseStatement "INSERT INTO users (id, name, age) VALUES (7, 'A', 1), (8, 'B', 2)" >>= runStatement testDB of
|
||
Left err -> expectationFailure err
|
||
Right (db', _) -> do
|
||
rowsOf (parseStatement "SELECT name FROM users WHERE age < 3" >>= runStatement db')
|
||
`shouldBe` Right [[("name", VStr "A")], [("name", VStr "B")]]
|
||
runStatement testDB (Insert "users" ["id", "name"] [[LitInt 1, LitStr "A"], [LitInt 2]])
|
||
`shouldSatisfy` isLeft
|
||
|
||
describe "ChuSQL.Core.Engine (INDEX)" $ do
|
||
it "parses CREATE INDEX and DROP INDEX" $ do
|
||
parseStatement "CREATE INDEX ON users (age)"
|
||
`shouldBe` Right (CreateIndex "users" "age")
|
||
parseStatement "DROP INDEX ON users (age)"
|
||
`shouldBe` Right (DropIndex "users" "age")
|
||
|
||
it "rejects an index on an unknown column" $ do
|
||
(parseStatement "CREATE INDEX ON users (nope)" >>= runStatement testDB)
|
||
`shouldSatisfy` isLeft
|
||
|
||
it "rejects an index on an unknown table" $ do
|
||
(parseStatement "CREATE INDEX ON nope (age)" >>= runStatement testDB)
|
||
`shouldSatisfy` isLeft
|
||
|
||
it "accepts index DDL in the memory storage (no-op there)" $ do
|
||
rowsOf (parseStatement "CREATE INDEX ON users (age)" >>= runStatement testDB)
|
||
`shouldBe` Right []
|
||
rowsOf (parseStatement "DROP INDEX ON users (age)" >>= runStatement testDB)
|
||
`shouldBe` Right []
|
||
|
||
describe "ChuSQL.Core.Engine (DROP COLUMN)" $ do
|
||
it "parses ALTER TABLE ... DROP COLUMN case-insensitively" $ do
|
||
parseStatement "ALTER TABLE users DROP COLUMN age"
|
||
`shouldBe` Right (DropColumn "users" "age")
|
||
parseStatement "alter table users drop column age"
|
||
`shouldBe` Right (DropColumn "users" "age")
|
||
|
||
it "after execution the column definition and every row cell are gone, other columns untouched" $ do
|
||
case runStatement testDB (DropColumn "users" "age") of
|
||
Left err -> expectationFailure err
|
||
Right (db', _) -> do
|
||
case lookup "users" db' of
|
||
Nothing -> expectationFailure "users table is gone"
|
||
Just t -> do
|
||
map fst (tableCols t) `shouldBe` ["id", "name"]
|
||
tableRows t `shouldSatisfy` all (all (\(k, _) -> k /= "age"))
|
||
let rowCountBefore = maybe 0 (length . tableRows) (lookup "users" testDB)
|
||
length (tableRows t) `shouldBe` rowCountBefore
|
||
tableRows t `shouldBe` map (filter ((/= "age") . fst)) (maybe [] tableRows (lookup "users" testDB))
|
||
|
||
it "unknown column or unknown table reports an error" $ do
|
||
(parseStatement "ALTER TABLE users DROP COLUMN nope" >>= runStatement testDB)
|
||
`shouldSatisfy` isLeft
|
||
(parseStatement "ALTER TABLE nope DROP COLUMN age" >>= runStatement testDB)
|
||
`shouldSatisfy` isLeft
|
||
|
||
it "the built-in id column cannot be dropped (the UI locates a row by it)" $ do
|
||
(parseStatement "ALTER TABLE users DROP COLUMN id" >>= runStatement testDB)
|
||
`shouldSatisfy` isLeft
|
||
describe "ChuSQL.Core.Engine (USER)" $ do
|
||
it "parses CREATE USER / ALTER USER / DROP USER case-insensitively" $ do
|
||
parseStatement "CREATE USER alice IDENTIFIED BY 's3cret'"
|
||
`shouldBe` Right (CreateUser "alice" "s3cret")
|
||
parseStatement "create user alice identified by 's3cret'"
|
||
`shouldBe` Right (CreateUser "alice" "s3cret")
|
||
parseStatement "ALTER USER alice IDENTIFIED BY 'next-one'"
|
||
`shouldBe` Right (AlterUser "alice" "next-one")
|
||
parseStatement "alter user alice identified by 'next-one'"
|
||
`shouldBe` Right (AlterUser "alice" "next-one")
|
||
parseStatement "DROP USER alice"
|
||
`shouldBe` Right (DropUser "alice")
|
||
|
||
it "accepts a quoted account name so dot and dash are usable" $ do
|
||
parseStatement "CREATE USER 'a.b-c' IDENTIFIED BY 'p'"
|
||
`shouldBe` Right (CreateUser "a.b-c" "p")
|
||
parseStatement "DROP USER 'a.b-c'"
|
||
`shouldBe` Right (DropUser "a.b-c")
|
||
|
||
it "keeps a doubled quote inside the password" $ do
|
||
parseStatement "CREATE USER alice IDENTIFIED BY 'it''s ok'"
|
||
`shouldBe` Right (CreateUser "alice" "it's ok")
|
||
|
||
it "an empty password or a missing clause is an error" $ do
|
||
(parseStatement "CREATE USER alice IDENTIFIED BY ''" >>= prepare testDB)
|
||
`shouldSatisfy` isLeft
|
||
parseStatement "CREATE USER alice" `shouldSatisfy` isLeft
|
||
parseStatement "CREATE USER" `shouldSatisfy` isLeft
|
||
parseStatement "DROP USER" `shouldSatisfy` isLeft
|
||
|
||
it "refuses to invent account rows in the query engine" $ do
|
||
(parseStatement "CREATE USER alice IDENTIFIED BY 's3cret'" >>= runStatement testDB)
|
||
`shouldSatisfy` isLeft
|
||
(parseStatement "ALTER USER alice IDENTIFIED BY 'next'" >>= runStatement testDB)
|
||
`shouldSatisfy` isLeft
|
||
(parseStatement "DROP USER alice" >>= runStatement testDB)
|
||
`shouldSatisfy` isLeft
|
||
|
||
describe "ChuSQL.Core.Engine (ROLE)" $ do
|
||
it "parses CREATE ROLE / DROP ROLE case-insensitively" $ do
|
||
parseStatement "CREATE ROLE analyst" `shouldBe` Right (CreateRole "analyst")
|
||
parseStatement "create role analyst" `shouldBe` Right (CreateRole "analyst")
|
||
parseStatement "DROP ROLE analyst" `shouldBe` Right (DropRole "analyst")
|
||
parseStatement "drop role 'a.b'" `shouldBe` Right (DropRole "a.b")
|
||
|
||
it "parses privilege grants on one table, on another database's table or on everything" $ do
|
||
parseStatement "GRANT SELECT ON users TO analyst"
|
||
`shouldBe` Right (GrantPrivileges ["SELECT"] "users" "analyst")
|
||
parseStatement "grant select, insert on users to analyst"
|
||
`shouldBe` Right (GrantPrivileges ["SELECT", "INSERT"] "users" "analyst")
|
||
parseStatement "GRANT ALL ON sales.orders TO analyst"
|
||
`shouldBe` Right (GrantPrivileges ["ALL"] "sales.orders" "analyst")
|
||
parseStatement "GRANT DELETE ON * TO analyst"
|
||
`shouldBe` Right (GrantPrivileges ["DELETE"] "*" "analyst")
|
||
|
||
it "parses REVOKE symmetrically" $ do
|
||
parseStatement "REVOKE SELECT ON users FROM analyst"
|
||
`shouldBe` Right (RevokePrivileges ["SELECT"] "users" "analyst")
|
||
parseStatement "revoke update, delete on * from analyst"
|
||
`shouldBe` Right (RevokePrivileges ["UPDATE", "DELETE"] "*" "analyst")
|
||
|
||
it "tells a role membership apart from a privilege grant" $ do
|
||
parseStatement "GRANT analyst TO alice"
|
||
`shouldBe` Right (GrantRole "analyst" ["alice"])
|
||
parseStatement "grant analyst to alice, bob"
|
||
`shouldBe` Right (GrantRole "analyst" ["alice", "bob"])
|
||
parseStatement "REVOKE analyst FROM alice, bob"
|
||
`shouldBe` Right (RevokeRole "analyst" ["alice", "bob"])
|
||
|
||
it "an unknown privilege or a missing clause is an error" $ do
|
||
parseStatement "GRANT EXECUTE ON users TO analyst" `shouldSatisfy` isLeft
|
||
parseStatement "GRANT SELECT ON users" `shouldSatisfy` isLeft
|
||
parseStatement "GRANT analyst TO" `shouldSatisfy` isLeft
|
||
parseStatement "REVOKE analyst FROM" `shouldSatisfy` isLeft
|
||
parseStatement "CREATE ROLE" `shouldSatisfy` isLeft
|
||
parseStatement "DROP ROLE" `shouldSatisfy` isLeft
|
||
|
||
it "checks role names, privileges and grant objects before execution" $ do
|
||
(parseStatement "CREATE ROLE select" >>= prepare testDB) `shouldSatisfy` isLeft
|
||
(parseStatement "DROP ROLE insert" >>= prepare testDB) `shouldSatisfy` isLeft
|
||
(parseStatement "GRANT SELECT ON users TO analyst" >>= prepare testDB)
|
||
`shouldBe` Right (GrantPrivileges ["SELECT"] "users" "analyst")
|
||
(parseStatement "GRANT SELECT ON users TO select" >>= prepare testDB) `shouldSatisfy` isLeft
|
||
(parseStatement "REVOKE SELECT ON users FROM analyst" >>= prepare testDB)
|
||
`shouldBe` Right (RevokePrivileges ["SELECT"] "users" "analyst")
|
||
(parseStatement "GRANT analyst TO alice, bob" >>= prepare testDB)
|
||
`shouldBe` Right (GrantRole "analyst" ["alice", "bob"])
|
||
(parseStatement "REVOKE analyst FROM alice" >>= prepare testDB)
|
||
`shouldBe` Right (RevokeRole "analyst" ["alice"])
|
||
|
||
it "refuses to invent roles in the query engine (the web services own them)" $ do
|
||
(parseStatement "CREATE ROLE analyst" >>= runStatement testDB) `shouldSatisfy` isLeft
|
||
(parseStatement "DROP ROLE analyst" >>= runStatement testDB) `shouldSatisfy` isLeft
|
||
(parseStatement "GRANT SELECT ON users TO analyst" >>= runStatement testDB) `shouldSatisfy` isLeft
|
||
(parseStatement "REVOKE SELECT ON users FROM analyst" >>= runStatement testDB) `shouldSatisfy` isLeft
|
||
(parseStatement "GRANT analyst TO alice" >>= runStatement testDB) `shouldSatisfy` isLeft
|
||
(parseStatement "REVOKE analyst FROM alice" >>= runStatement testDB) `shouldSatisfy` isLeft
|
||
|
||
it "user is reserved as an alias but still usable inside a longer name" $ do
|
||
parseStatement "SELECT * FROM users user" `shouldSatisfy` isLeft
|
||
parseStatement "SELECT user1 FROM users" `shouldBe` Right (Select ["user1"] (FromTable Nothing "users") Nothing [] [] Nothing)
|
||
|
||
describe "ChuSQL.Core.Engine (DELETE)" $ do
|
||
it "parses DELETE with WHERE" $ do
|
||
parseStatement "DELETE FROM users WHERE age < 18"
|
||
`shouldBe` Right (Delete "users" (Just (Lt (Col "age") (LitInt 18))))
|
||
|
||
it "parses DELETE without WHERE" $ do
|
||
parseStatement "DELETE FROM users"
|
||
`shouldBe` Right (Delete "users" Nothing)
|
||
|
||
it "executes DELETE with WHERE, removing matching rows" $ do
|
||
case runStatement testDB (Delete "users" (Just (Lt (Col "age") (LitInt 18)))) of
|
||
Left err -> expectationFailure err
|
||
Right (db', _) -> do
|
||
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 runStatement testDB (Delete "users" Nothing) of
|
||
Left err -> expectationFailure err
|
||
Right (db', _) -> do
|
||
rowsOf (runStatement db' (makeSelect ["name"] "users" Nothing))
|
||
`shouldBe` Right []
|
||
|
||
it "returns Left when WHERE references an unknown column" $ do
|
||
runStatement testDB (Delete "users" (Just (Gt (Col "unknown") (LitInt 18))))
|
||
`shouldSatisfy` isLeft
|
||
|
||
describe "ChuSQL.Core.Engine (UPDATE)" $ do
|
||
it "parses UPDATE with WHERE" $ do
|
||
parseStatement "UPDATE users SET age = 26 WHERE name = 'Alice'"
|
||
`shouldBe` Right
|
||
( Update
|
||
"users"
|
||
[("age", LitInt 26)]
|
||
(Just (Eq (Col "name") (LitStr "Alice")))
|
||
)
|
||
|
||
it "parses UPDATE without WHERE" $ do
|
||
parseStatement "UPDATE users SET age = 26"
|
||
`shouldBe` Right
|
||
(Update "users" [("age", LitInt 26)] Nothing)
|
||
|
||
it "parses multiple assignments" $ do
|
||
parseStatement "UPDATE users SET age = 26, name = 'Dave'"
|
||
`shouldBe` Right
|
||
( Update
|
||
"users"
|
||
[("age", LitInt 26), ("name", LitStr "Dave")]
|
||
Nothing
|
||
)
|
||
|
||
it "executes UPDATE with WHERE, modifying matching rows" $ do
|
||
case runStatement testDB (Update "users" [("age", LitInt 99)] (Just (Eq (Col "name") (LitStr "Alice")))) of
|
||
Left err -> expectationFailure err
|
||
Right (db', _) -> do
|
||
rowsOf (runStatement db' (makeSelect ["name", "age"] "users" Nothing))
|
||
`shouldBe` Right
|
||
[ [("name", VStr "Alice"), ("age", VInt 99)]
|
||
, [("name", VStr "Bob"), ("age", VInt 17)]
|
||
, [("name", VStr "Carol"), ("age", VInt 30)]
|
||
]
|
||
|
||
it "executes UPDATE without WHERE, modifying all rows" $ do
|
||
case runStatement testDB (Update "users" [("age", LitInt 0)] Nothing) of
|
||
Left err -> expectationFailure err
|
||
Right (db', _) -> do
|
||
rowsOf (runStatement db' (makeSelect ["name", "age"] "users" Nothing))
|
||
`shouldBe` Right
|
||
[ [("name", VStr "Alice"), ("age", VInt 0)]
|
||
, [("name", VStr "Bob"), ("age", VInt 0)]
|
||
, [("name", VStr "Carol"), ("age", VInt 0)]
|
||
]
|
||
|
||
it "applies multiple assignments to each row" $ do
|
||
case runStatement testDB (Update "users" [("age", LitInt 99), ("name", LitStr "X")] Nothing) of
|
||
Left err -> expectationFailure err
|
||
Right (db', _) -> do
|
||
rowsOf (runStatement db' (makeSelect ["name", "age"] "users" Nothing))
|
||
`shouldBe` Right
|
||
[ [("name", VStr "X"), ("age", VInt 99)]
|
||
, [("name", VStr "X"), ("age", VInt 99)]
|
||
, [("name", VStr "X"), ("age", VInt 99)]
|
||
]
|
||
|
||
it "returns Left when WHERE references an unknown column" $ do
|
||
runStatement testDB (Update "users" [("age", LitInt 0)] (Just (Gt (Col "unknown") (LitInt 1))))
|
||
`shouldSatisfy` isLeft
|
||
|
||
describe "ChuSQL.Core.Engine (ORDER BY)" $ do
|
||
it "parses ORDER BY DESC" $ do
|
||
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 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 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 "executes ORDER BY age ASC" $ do
|
||
let q =
|
||
Select
|
||
{ selectCols = ["name"]
|
||
, selectFrom = FromTable Nothing "users"
|
||
, selectWhere = Nothing
|
||
, selectGroupBy = []
|
||
, selectOrderBy = [("age", Asc)]
|
||
, selectLimit = Nothing
|
||
}
|
||
rowsOf (runStatement testDB q)
|
||
`shouldBe` Right
|
||
[ [("name", VStr "Bob")]
|
||
, [("name", VStr "Alice")]
|
||
, [("name", VStr "Carol")]
|
||
]
|
||
|
||
it "executes ORDER BY age DESC" $ do
|
||
let q =
|
||
Select
|
||
{ selectCols = ["name"]
|
||
, selectFrom = FromTable Nothing "users"
|
||
, selectWhere = Nothing
|
||
, selectGroupBy = []
|
||
, selectOrderBy = [("age", Desc)]
|
||
, selectLimit = Nothing
|
||
}
|
||
rowsOf (runStatement testDB q)
|
||
`shouldBe` Right
|
||
[ [("name", VStr "Carol")]
|
||
, [("name", VStr "Alice")]
|
||
, [("name", VStr "Bob")]
|
||
]
|
||
|
||
describe "ChuSQL.Core.Engine (LIMIT)" $ do
|
||
it "parses LIMIT" $ do
|
||
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 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 "executes LIMIT without ORDER BY" $ do
|
||
let q =
|
||
Select
|
||
{ selectCols = ["name"]
|
||
, selectFrom = FromTable Nothing "users"
|
||
, selectWhere = Nothing
|
||
, selectGroupBy = []
|
||
, selectOrderBy = []
|
||
, selectLimit = Just 2
|
||
}
|
||
rowsOf (runStatement testDB q)
|
||
`shouldBe` Right
|
||
[ [("name", VStr "Alice")]
|
||
, [("name", VStr "Bob")]
|
||
]
|
||
|
||
it "executes LIMIT with ORDER BY" $ do
|
||
let q =
|
||
Select
|
||
{ selectCols = ["name"]
|
||
, selectFrom = FromTable Nothing "users"
|
||
, selectWhere = Nothing
|
||
, selectGroupBy = []
|
||
, selectOrderBy = [("age", Desc)]
|
||
, selectLimit = Just 2
|
||
}
|
||
rowsOf (runStatement testDB q)
|
||
`shouldBe` Right
|
||
[ [("name", VStr "Carol")]
|
||
, [("name", VStr "Alice")]
|
||
]
|
||
|
||
it "executes LIMIT 0" $ do
|
||
let q =
|
||
Select
|
||
{ selectCols = ["name"]
|
||
, selectFrom = FromTable Nothing "users"
|
||
, selectWhere = Nothing
|
||
, selectGroupBy = []
|
||
, selectOrderBy = []
|
||
, selectLimit = Just 0
|
||
}
|
||
rowsOf (runStatement testDB q)
|
||
`shouldBe` Right []
|
||
|
||
it "rejects negative LIMIT" $ do
|
||
parseStatement "SELECT * FROM users LIMIT -1"
|
||
`shouldSatisfy` isLeft
|
||
|
||
describe "ChuSQL.Core.Engine (JOIN)" $ do
|
||
it "executes a two-table JOIN with aliases" $ do
|
||
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")]
|
||
, [("u.name", VStr "Bob"), ("o.product", VStr "Pen")]
|
||
]
|
||
|
||
it "executes JOIN with WHERE" $ do
|
||
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 (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")]
|
||
, [("u.name", VStr "Alice"), ("o.product", VStr "Book")]
|
||
]
|
||
|
||
it "executes JOIN with LIMIT" $ do
|
||
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
|
||
runStatement
|
||
testDB
|
||
( Select
|
||
{ selectCols = ["u.name"]
|
||
, selectFrom =
|
||
FromJoin
|
||
InnerJoin
|
||
(FromTable (Just "u") "users")
|
||
(Just "x")
|
||
"nonexistent"
|
||
(Eq (Col "u.id") (Col "x.id"))
|
||
, selectWhere = Nothing
|
||
, selectGroupBy = []
|
||
, selectOrderBy = []
|
||
, selectLimit = Nothing
|
||
}
|
||
)
|
||
`shouldSatisfy` isLeft
|
||
|
||
describe "ChuSQL.Core.Engine (LEFT JOIN)" $ do
|
||
-- sql:在默认库上跑 SQL 取结果行
|
||
let sql input = parseStatement input >>= rowsOf . runStatement testDB
|
||
|
||
it "keeps unmatched left rows and fills the right side with NULL" $ do
|
||
sql "SELECT u.name, o.product FROM users u LEFT JOIN orders o ON u.id = o.user_id"
|
||
`shouldBe` Right
|
||
[ [("u.name", VStr "Alice"), ("o.product", VStr "Book")]
|
||
, [("u.name", VStr "Alice"), ("o.product", VStr "Cup")]
|
||
, [("u.name", VStr "Bob"), ("o.product", VStr "Pen")]
|
||
, [("u.name", VStr "Carol"), ("o.product", VNull)]
|
||
]
|
||
|
||
it "emits NULL rows even when the right table is empty" $ do
|
||
let db = [("users", users), ("orders", orders {tableRows = []})]
|
||
(parseStatement "SELECT u.name, o.product FROM users u LEFT JOIN orders o ON u.id = o.user_id" >>= rowsOf . runStatement db)
|
||
`shouldBe` Right
|
||
[ [("u.name", VStr "Alice"), ("o.product", VNull)]
|
||
, [("u.name", VStr "Bob"), ("o.product", VNull)]
|
||
, [("u.name", VStr "Carol"), ("o.product", VNull)]
|
||
]
|
||
|
||
it "a WHERE on the right side drops the NULL rows again" $ do
|
||
sql "SELECT u.name, o.product FROM users u LEFT JOIN orders o ON u.id = o.user_id WHERE o.product IS NOT NULL"
|
||
`shouldBe` Right
|
||
[ [("u.name", VStr "Alice"), ("o.product", VStr "Book")]
|
||
, [("u.name", VStr "Alice"), ("o.product", VStr "Cup")]
|
||
, [("u.name", VStr "Bob"), ("o.product", VStr "Pen")]
|
||
]
|
||
|
||
it "a WHERE on the left side keeps the NULL rows" $ do
|
||
sql "SELECT u.name, o.product FROM users u LEFT JOIN orders o ON u.id = o.user_id WHERE u.age > 18"
|
||
`shouldBe` Right
|
||
[ [("u.name", VStr "Alice"), ("o.product", VStr "Book")]
|
||
, [("u.name", VStr "Alice"), ("o.product", VStr "Cup")]
|
||
, [("u.name", VStr "Carol"), ("o.product", VNull)]
|
||
]
|
||
|
||
it "matches INNER JOIN when every left row has a match" $ do
|
||
sql "SELECT u.name, o.product FROM users u LEFT JOIN orders o ON u.id = o.user_id WHERE o.product IS NOT NULL ORDER BY o.product"
|
||
`shouldBe` sql "SELECT u.name, o.product FROM users u JOIN orders o ON u.id = o.user_id ORDER BY o.product"
|
||
|
||
it "keeps LEFT JOIN results identical after optimization" $ do
|
||
sameResultAsUnoptimized "SELECT u.name, o.product FROM users u LEFT JOIN orders o ON u.id = o.user_id"
|
||
sameResultAsUnoptimized "SELECT u.name, o.product FROM users u LEFT JOIN orders o ON u.id = o.user_id WHERE u.age > 18"
|
||
|
||
it "keeps the LeftJoin node and does not push a right-side predicate across it" $ do
|
||
fmap (leftJoinKinds . optimize testDB) (parseStatement "SELECT u.name FROM users u LEFT JOIN orders o ON u.id = o.user_id WHERE o.product = 'Book'" >>= translate)
|
||
`shouldBe` Right [True]
|
||
|
||
it "a non-equi ON goes through the nested loop path and still fills NULLs" $ do
|
||
sql "SELECT u.name, o.product FROM users u LEFT JOIN orders o ON u.id > o.user_id"
|
||
`shouldBe` Right
|
||
[ [("u.name", VStr "Alice"), ("o.product", VNull)]
|
||
, [("u.name", VStr "Bob"), ("o.product", VStr "Book")]
|
||
, [("u.name", VStr "Bob"), ("o.product", VStr "Cup")]
|
||
, [("u.name", VStr "Carol"), ("o.product", VStr "Book")]
|
||
, [("u.name", VStr "Carol"), ("o.product", VStr "Pen")]
|
||
, [("u.name", VStr "Carol"), ("o.product", VStr "Cup")]
|
||
]
|
||
|
||
describe "ChuSQL.Core.Engine (JOIN 嵌套循环)" $ do
|
||
-- sql:在默认库上跑 SQL 取结果行
|
||
let sql input = parseStatement input >>= rowsOf . runStatement testDB
|
||
|
||
it "keeps columns from both sides when the ON has no equi key" $ do
|
||
sql "SELECT u.name, o.product FROM users u JOIN orders o ON u.id > o.user_id"
|
||
`shouldBe` Right
|
||
[ [("u.name", VStr "Bob"), ("o.product", VStr "Book")]
|
||
, [("u.name", VStr "Bob"), ("o.product", VStr "Cup")]
|
||
, [("u.name", VStr "Carol"), ("o.product", VStr "Book")]
|
||
, [("u.name", VStr "Carol"), ("o.product", VStr "Pen")]
|
||
, [("u.name", VStr "Carol"), ("o.product", VStr "Cup")]
|
||
]
|
||
|
||
describe "ChuSQL.Core.Engine (子查询)" $ do
|
||
-- sql:在默认库上跑 SQL 取结果行
|
||
let sql input = parseStatement input >>= rowsOf . runStatement testDB
|
||
|
||
it "IN (subquery) keeps the rows whose value the subquery returns" $ do
|
||
sql "SELECT name FROM users WHERE id IN (SELECT user_id FROM orders)"
|
||
`shouldBe` Right [[("name", VStr "Alice")], [("name", VStr "Bob")]]
|
||
|
||
it "NOT IN (subquery) keeps the other rows" $ do
|
||
sql "SELECT name FROM users WHERE id NOT IN (SELECT user_id FROM orders)"
|
||
`shouldBe` Right [[("name", VStr "Carol")]]
|
||
|
||
it "EXISTS works as a correlated subquery" $ do
|
||
sql "SELECT u.name FROM users u WHERE EXISTS (SELECT * FROM orders o WHERE o.user_id = u.id)"
|
||
`shouldBe` Right [[("u.name", VStr "Alice")], [("u.name", VStr "Bob")]]
|
||
|
||
it "NOT EXISTS keeps the rows with no match" $ do
|
||
sql "SELECT u.name FROM users u WHERE NOT EXISTS (SELECT * FROM orders o WHERE o.user_id = u.id)"
|
||
`shouldBe` Right [[("u.name", VStr "Carol")]]
|
||
|
||
it "a scalar subquery is compared as a single value" $ do
|
||
sql "SELECT name FROM users WHERE age > (SELECT AVG(age) FROM users)"
|
||
`shouldBe` Right [[("name", VStr "Alice")], [("name", VStr "Carol")]]
|
||
|
||
it "a scalar subquery with no rows behaves like NULL" $ do
|
||
sql "SELECT name FROM users WHERE age > (SELECT age FROM users WHERE id = 99)"
|
||
`shouldBe` Right []
|
||
|
||
it "rejects a scalar subquery that returns more than one row" $ do
|
||
(parseStatement "SELECT name FROM users WHERE age > (SELECT age FROM users)" >>= runStatement testDB)
|
||
`shouldSatisfy` isLeft
|
||
|
||
it "rejects a scalar subquery with more than one column" $ do
|
||
(parseStatement "SELECT name FROM users WHERE age > (SELECT id, age FROM users)" >>= runStatement testDB)
|
||
`shouldSatisfy` isLeft
|
||
|
||
it "rejects an unknown column inside a subquery" $ do
|
||
(parseStatement "SELECT name FROM users WHERE id IN (SELECT nope FROM orders)" >>= runStatement testDB)
|
||
`shouldSatisfy` isLeft
|
||
|
||
it "rejects a subquery that is not a SELECT" $ do
|
||
runStatement testDB (Delete "users" (Just (ExistsSub (Subquery (DropTable "orders") []) False)))
|
||
`shouldSatisfy` isLeft
|
||
|
||
it "runs a subquery in a JOIN condition" $ do
|
||
sql "SELECT u.name, o.product FROM users u JOIN orders o ON u.id = o.user_id AND o.product IN (SELECT product FROM orders WHERE user_id = 1)"
|
||
`shouldBe` Right
|
||
[ [("u.name", VStr "Alice"), ("o.product", VStr "Book")]
|
||
, [("u.name", VStr "Alice"), ("o.product", VStr "Cup")]
|
||
]
|
||
|
||
it "keeps subquery results identical after optimization" $ do
|
||
sameResultAsUnoptimized "SELECT name FROM users WHERE id IN (SELECT user_id FROM orders)"
|
||
sameResultAsUnoptimized "SELECT u.name FROM users u WHERE EXISTS (SELECT * FROM orders o WHERE o.user_id = u.id)"
|
||
|
||
describe "ChuSQL.Core.Engine (聚合与 GROUP BY)" $ do
|
||
-- sql:在默认库上跑 SQL 取结果行
|
||
let sql input = parseStatement input >>= rowsOf . runStatement testDB
|
||
-- sqlDb:在指定库上跑 SQL
|
||
sqlDb db input = parseStatement input >>= rowsOf . runStatement db
|
||
-- vals:只取结果里的值
|
||
vals input = fmap (map (map snd)) (sql input)
|
||
|
||
it "counts rows without GROUP BY" $ do
|
||
vals "SELECT COUNT(*) FROM users" `shouldBe` Right [[VInt 3]]
|
||
|
||
it "counts only the non-null values of a column" $ do
|
||
let db = [(n, if n == "users" then t {tableRows = [[("id", VInt 1), ("name", VStr "a"), ("age", VNull)]]} else t) | (n, t) <- testDB]
|
||
fmap (map (map snd)) (sqlDb db "SELECT COUNT(age), COUNT(*) FROM users")
|
||
`shouldBe` Right [[VInt 0, VInt 1]]
|
||
|
||
it "sums, averages and takes extremes" $ do
|
||
vals "SELECT SUM(age), AVG(age), MIN(age), MAX(age) FROM users"
|
||
`shouldBe` Right [[VInt 72, VFloat 24, VInt 17, VInt 30]]
|
||
|
||
it "ignores nulls in the aggregates that skip them" $ do
|
||
let db = [(n, if n == "users" then t {tableRows = [[("id", VInt 1), ("name", VStr "a"), ("age", VNull)], [("id", VInt 2), ("name", VStr "b"), ("age", VInt 4)]]} else t) | (n, t) <- testDB]
|
||
fmap (map (map snd)) (sqlDb db "SELECT SUM(age), AVG(age), MIN(age), MAX(age) FROM users")
|
||
`shouldBe` Right [[VInt 4, VFloat 4, VInt 4, VInt 4]]
|
||
|
||
it "groups by a column and counts each group" $ do
|
||
vals "SELECT user_id, COUNT(*) FROM orders GROUP BY user_id ORDER BY user_id"
|
||
`shouldBe` Right [[VInt 1, VInt 2], [VInt 2, VInt 1]]
|
||
|
||
it "keeps the group key in the output row" $ do
|
||
fmap (map (map fst)) (sql "SELECT age, COUNT(*) FROM users GROUP BY age ORDER BY age")
|
||
`shouldBe` Right [["age", "COUNT(*)"], ["age", "COUNT(*)"], ["age", "COUNT(*)"]]
|
||
|
||
it "computes expressions on top of aggregate results" $ do
|
||
vals "SELECT SUM(age) / 2 FROM users" `shouldBe` Right [[VInt 36]]
|
||
|
||
it "uses the same aggregate only once" $ do
|
||
case parseStatement "SELECT SUM(age), SUM(age) + 1 FROM users" of
|
||
Left err -> expectationFailure err
|
||
Right q -> case translate =<< prepare testDB q of
|
||
Left err -> expectationFailure err
|
||
Right plan -> case plan of
|
||
Compute _ (Aggregate _ aggs _) -> length aggs `shouldBe` 1
|
||
other -> expectationFailure ("unexpected plan: " ++ renderPlan other)
|
||
|
||
it "returns one row with null sum for an empty table" $ do
|
||
let empty = [(n, if n == "users" then t {tableRows = []} else t) | (n, t) <- testDB]
|
||
fmap (map (map snd)) (sqlDb empty "SELECT COUNT(*), SUM(age) FROM users")
|
||
`shouldBe` Right [[VInt 0, VNull]]
|
||
|
||
it "returns no groups for an empty table when grouping" $ do
|
||
let empty = [(n, if n == "users" then t {tableRows = []} else t) | (n, t) <- testDB]
|
||
sqlDb empty "SELECT age, COUNT(*) FROM users GROUP BY age" `shouldBe` Right []
|
||
|
||
it "rejects a bare column that is not grouped" $ do
|
||
sql "SELECT name, COUNT(*) FROM users GROUP BY age"
|
||
`shouldSatisfy` either (isInfixOf "must appear in GROUP BY") (const False)
|
||
|
||
it "rejects an aggregate in WHERE" $ do
|
||
sql "SELECT name FROM users WHERE COUNT(*) > 1"
|
||
`shouldSatisfy` either (isInfixOf "aggregate functions are not allowed here") (const False)
|
||
|
||
it "rejects nested aggregates" $ do
|
||
sql "SELECT SUM(COUNT(*)) FROM users"
|
||
`shouldSatisfy` either (isInfixOf "nested aggregates") (const False)
|
||
|
||
it "rejects SUM on a text column" $ do
|
||
sql "SELECT SUM(name) FROM users"
|
||
`shouldSatisfy` either (isInfixOf "needs a number") (const False)
|
||
|
||
it "rejects an unknown GROUP BY column" $ do
|
||
sql "SELECT age, COUNT(*) FROM users GROUP BY nope"
|
||
`shouldSatisfy` either (isInfixOf "unknown column") (const False)
|
||
|
||
it "parses GROUP BY and the aggregate calls" $ do
|
||
case parseStatement "SELECT age, COUNT(*), SUM(age) FROM users GROUP BY age" of
|
||
Left err -> expectationFailure err
|
||
Right q -> do
|
||
selectGroupBy q `shouldBe` ["age"]
|
||
selectItems q
|
||
`shouldBe` [ ("age", Col "age")
|
||
, ("COUNT(*)", CountAll)
|
||
, ("SUM(age)", SumOf (Col "age"))
|
||
]
|
||
|
||
it "keeps grouped results identical after optimization" $ do
|
||
let query = "SELECT user_id, COUNT(*) FROM orders GROUP BY user_id ORDER BY user_id"
|
||
case parseStatement query >>= prepare testDB >>= translate of
|
||
Left err -> expectationFailure err
|
||
Right plan -> do
|
||
evalRelOp testDB plan `shouldBe` Right [[("user_id", VInt 1), ("COUNT(*)", VInt 2)], [("user_id", VInt 2), ("COUNT(*)", VInt 1)]]
|
||
evalRelOp testDB (optimize testDB plan) `shouldBe` evalRelOp testDB plan
|
||
|
||
describe "ChuSQL end-to-end" $ do
|
||
it "parses and executes a complex query" $ do
|
||
rowsOf
|
||
( parseStatement "SELECT name FROM users WHERE age < 18 OR age > 28"
|
||
>>= runStatement testDB
|
||
)
|
||
`shouldBe` Right
|
||
[ [("name", VStr "Bob")]
|
||
, [("name", VStr "Carol")]
|
||
]
|
||
|
||
it "WHERE ORDER BY LIMIT end-to-end" $ do
|
||
rowsOf (parseStatement "SELECT name FROM users WHERE age > 15 ORDER BY age DESC LIMIT 1" >>= runStatement testDB)
|
||
`shouldBe` Right
|
||
[ [("name", VStr "Carol")]
|
||
]
|
||
|
||
describe "ChuSQL.Core.Engine.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 a JOIN without an equi key (nested loop path) results identical" $ do
|
||
sameResultAsUnoptimized "SELECT u.name, o.product FROM users u JOIN orders o ON u.id > o.user_id"
|
||
|
||
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 "pushes a single-side predicate into the left side of a JOIN" $ do
|
||
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
|
||
Left err -> expectationFailure err
|
||
Right relOp ->
|
||
filterPushedIntoLeft (optimize testDB relOp) `shouldBe` True
|
||
|
||
it "removes a redundant SELECT * projection" $ do
|
||
case parseStatement "SELECT * FROM users" of
|
||
Left err -> expectationFailure err
|
||
Right q ->
|
||
case translate q of
|
||
Left err -> expectationFailure err
|
||
Right relOp ->
|
||
optimize testDB relOp `shouldBe` Scan Nothing "users" Nothing
|
||
|
||
it "does not push a filter through LIMIT" $ do
|
||
let cond = Gt (Col "age") (LitInt 18)
|
||
relOp = Filter cond (Limit 2 (Scan Nothing "users" Nothing))
|
||
optimize testDB relOp `shouldBe` relOp
|
||
|
||
it "is idempotent after reaching a fixed point" $ do
|
||
case parseStatement "SELECT u.name, o.product FROM users u JOIN orders o ON u.id = o.user_id WHERE u.age > 20" of
|
||
Left err -> expectationFailure err
|
||
Right q ->
|
||
case translate 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 "picks the index only when the statistics make it cheaper" $ do
|
||
let stat rows distinct indexes = TableMeta rows [(c, d, False) | (c, d) <- distinct] indexes
|
||
dbWith m = [("users", Table "users" [("id", TInt), ("name", TStr)] [] (Just m))]
|
||
unknown = [("users", Table "users" [("id", TInt), ("name", TStr)] [] Nothing)]
|
||
point = Filter (Eq (Col "id") (LitInt 1)) (Scan Nothing "users" Nothing)
|
||
range = Filter (Gt (Col "id") (LitInt 5)) (Scan Nothing "users" Nothing)
|
||
-- 索引在、点查命中一行:改写
|
||
optimize (dbWith (stat 10000 [("id", 10000)] ["id"])) point
|
||
`shouldBe` Lookup Nothing "users" "id" (VInt 1)
|
||
-- 拿不到统计:保持原有改写行为
|
||
optimize unknown point `shouldBe` Lookup Nothing "users" "id" (VInt 1)
|
||
-- 没有索引:不改写,省掉一次白跑的存储层查询
|
||
optimize (dbWith (stat 10000 [("id", 10000)] [])) point `shouldBe` point
|
||
-- 有索引但整列一个值:点查等于全表,全表扫更便宜
|
||
optimize (dbWith (stat 10000 [("name", 1)] ["name"])) (Filter (Eq (Col "name") (LitStr "a")) (Scan Nothing "users" Nothing))
|
||
`shouldBe` Filter (Eq (Col "name") (LitStr "a")) (Scan Nothing "users" Nothing)
|
||
-- 范围扫描同理
|
||
optimize (dbWith (stat 10000 [("id", 10000)] ["id"])) range
|
||
`shouldBe` Range Nothing "users" "id" (Just (VInt 5, False)) Nothing
|
||
optimize (dbWith (stat 10000 [("id", 10000)] [])) range `shouldBe` range
|
||
|
||
it "reorders an inner join chain only when the statistics say it is cheaper" $ do
|
||
let sql =
|
||
"SELECT a.name FROM a JOIN b ON a.id = b.a_id JOIN c ON b.id = c.b_id ORDER BY a.name"
|
||
unordered = "SELECT a.name FROM a JOIN b ON a.id = b.a_id JOIN c ON b.id = c.b_id"
|
||
-- 统计说先接最小的 c 更省:文本顺序 a / b / c 换成一个更便宜的顺序
|
||
fmap planTables (optimizedPlanIn chainDB sql) `shouldBe` Right ["b", "c", "a"]
|
||
-- 拿不到统计:保持文本顺序
|
||
fmap planTables (optimizedPlanIn chainDBWithoutStats sql) `shouldBe` Right ["a", "b", "c"]
|
||
-- 行序可观察(没有 ORDER BY):不动顺序
|
||
fmap planTables (optimizedPlanIn chainDB unordered) `shouldBe` Right ["a", "b", "c"]
|
||
|
||
it "keeps an inner join chain result identical when it reorders it" $ do
|
||
let sql =
|
||
"SELECT a.name FROM a JOIN b ON a.id = b.a_id JOIN c ON b.id = c.b_id ORDER BY a.name"
|
||
case parseStatement sql >>= prepare chainDB of
|
||
Left err -> expectationFailure err
|
||
Right q -> case translate q of
|
||
Left err -> expectationFailure err
|
||
Right relOp -> evalRelOp chainDB (optimize chainDB relOp) `shouldBe` evalRelOp chainDB relOp
|
||
|
||
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 an expression predicate as a filter" $ do
|
||
fmap firstFilterCond (optimizedPlan "SELECT name FROM users WHERE age + 1 > 18")
|
||
`shouldBe` Right (Just (Gt (Add (Col "age") (LitInt 1)) (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 "returns no rows for a constant-false predicate" $ do
|
||
rowsOf (parseStatement "SELECT name FROM users WHERE 1 > 2" >>= runStatement testDB)
|
||
`shouldBe` Right []
|
||
|
||
it "returns every row for a constant-true predicate" $ do
|
||
rowsOf (parseStatement "SELECT name FROM users WHERE 1 = 1" >>= runStatement testDB)
|
||
`shouldBe` Right
|
||
[ [("name", VStr "Alice")]
|
||
, [("name", VStr "Bob")]
|
||
, [("name", VStr "Carol")]
|
||
]
|
||
|
||
it "keeps both sides of a join" $ do
|
||
let plan =
|
||
Project
|
||
["u.name"]
|
||
( Join
|
||
InnerJoin
|
||
(Scan (Just "u") "users" Nothing)
|
||
(Scan (Just "o") "orders" Nothing)
|
||
(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" Nothing))
|
||
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" Nothing))
|
||
)
|
||
)
|
||
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" (Just ["name"])))
|
||
|
||
it "carries required columns in the scan plan" $ do
|
||
fmap renderPlan (optimizedPlan "SELECT name FROM users")
|
||
`shouldBe` Right "Project [\"name\"]\n Scan Nothing \"users\" columns=[\"name\"]"
|
||
|
||
it "preserves row count when an expression needs no columns" $ do
|
||
sameResultAsUnoptimized "SELECT 1 FROM users"
|
||
|
||
it "preserves duplicate projection labels and ordering dependencies" $ do
|
||
sameResultAsUnoptimized "SELECT name, name FROM users ORDER BY age DESC LIMIT 2"
|
||
|
||
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 with ORDER BY and LIMIT results identical" $ do
|
||
sameResultAsUnoptimized "SELECT u.name FROM users u JOIN orders o ON u.id = o.user_id ORDER BY o.product DESC LIMIT 2"
|
||
|
||
it "returns projected columns in the order the SELECT list asks for" $ do
|
||
rowsOf (parseStatement "SELECT age, name FROM users WHERE id = 1" >>= runStatement testDB)
|
||
`shouldBe` Right [[("age", VInt 25), ("name", VStr "Alice")]]
|
||
rowsOf (parseStatement "SELECT u.age, u.name FROM users u WHERE u.id = 1" >>= runStatement testDB)
|
||
`shouldBe` Right [[("u.age", VInt 25), ("u.name", VStr "Alice")]]
|
||
rowsOf (parseStatement "SELECT * FROM orders WHERE id = 1" >>= runStatement testDB)
|
||
`shouldBe` Right [[("id", VInt 1), ("user_id", VInt 1), ("product", VStr "Book")]]
|
||
it "rewrites Filter id = k into Lookup" $ do
|
||
fmap planRoot (optimizedPlan "SELECT name FROM users WHERE id = 2")
|
||
`shouldBe` Right (Lookup Nothing "users" "id" (VInt 2))
|
||
it "keeps Lookup result identical to unoptimized" $ do
|
||
sameResultAsUnoptimized "SELECT name FROM users WHERE id = 2"
|
||
|
||
it "rewrites an equality on any column into Lookup" $ do
|
||
fmap planRoot (optimizedPlan "SELECT name FROM users WHERE age = 25")
|
||
`shouldBe` Right (Lookup Nothing "users" "age" (VInt 25))
|
||
|
||
it "rewrites a string equality into Lookup too" $ do
|
||
fmap planRoot (optimizedPlan "SELECT name FROM users WHERE name = 'Alice'")
|
||
`shouldBe` Right (Lookup Nothing "users" "name" (VStr "Alice"))
|
||
sameResultAsUnoptimized "SELECT name FROM users WHERE name = 'Alice'"
|
||
|
||
it "rewrites a one-sided range into Range" $ do
|
||
fmap planRoot (optimizedPlan "SELECT name FROM users WHERE age > 20")
|
||
`shouldBe` Right (Range Nothing "users" "age" (Just (VInt 20, False)) Nothing)
|
||
fmap planRoot (optimizedPlan "SELECT name FROM users WHERE age < 20")
|
||
`shouldBe` Right (Range Nothing "users" "age" Nothing (Just (VInt 20, False)))
|
||
sameResultAsUnoptimized "SELECT name FROM users WHERE age > 20"
|
||
|
||
it "rewrites a two-sided range into Range" $ do
|
||
fmap planRoot (optimizedPlan "SELECT name FROM users WHERE age > 20 AND age < 30")
|
||
`shouldBe` Right (Range Nothing "users" "age" (Just (VInt 20, False)) (Just (VInt 30, False)))
|
||
sameResultAsUnoptimized "SELECT name FROM users WHERE age > 20 AND age < 30"
|
||
|
||
it "keeps a range on two different columns as a filter" $ do
|
||
fmap anyFilter (optimizedPlan "SELECT name FROM users WHERE age > 20 AND id < 3")
|
||
`shouldBe` Right True
|
||
|
||
it "returns the row for an aliased point lookup" $ do
|
||
rowsOf (parseStatement "SELECT u.name FROM users u WHERE u.id = 1" >>= runStatement testDB)
|
||
`shouldBe` Right [[("u.name", VStr "Alice")]]
|
||
|
||
describe "ChuSQL.Core.Engine.Semantic" $ do
|
||
it "rejects an unknown column in WHERE" $ do
|
||
(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
|
||
(parseStatement "SELECT name FROM users WHERE nope > 18" >>= runStatement emptyDB)
|
||
`shouldSatisfy` isLeft
|
||
|
||
it "rejects an unknown column in an ON condition" $ do
|
||
(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
|
||
(parseStatement "SELECT name FROM users WHERE name > 18" >>= runStatement testDB)
|
||
`shouldSatisfy` isLeft
|
||
|
||
it "rejects a type mismatch even when the table is empty" $ do
|
||
(parseStatement "SELECT name FROM users WHERE name > 18" >>= runStatement emptyDB)
|
||
`shouldSatisfy` isLeft
|
||
|
||
it "rejects a WHERE clause that is not a condition" $ do
|
||
(parseStatement "SELECT name FROM users WHERE age" >>= runStatement testDB)
|
||
`shouldSatisfy` isLeft
|
||
|
||
it "rejects a non-boolean WHERE even when the table is empty" $ do
|
||
(parseStatement "SELECT name FROM users WHERE age" >>= runStatement emptyDB)
|
||
`shouldSatisfy` isLeft
|
||
|
||
it "rejects a value whose type does not match the column" $ do
|
||
(parseStatement "INSERT INTO users (name, age) VALUES ('Dave', 'abc')" >>= runStatement testDB)
|
||
`shouldSatisfy` isLeft
|
||
|
||
it "rejects an unknown column in UPDATE SET" $ do
|
||
(parseStatement "UPDATE users SET nope = 1" >>= runStatement testDB)
|
||
`shouldSatisfy` isLeft
|
||
|
||
it "rejects an unknown column in DELETE WHERE" $ do
|
||
(parseStatement "DELETE FROM users WHERE nope > 1" >>= runStatement testDB)
|
||
`shouldSatisfy` isLeft
|
||
|
||
it "still accepts a valid query" $ do
|
||
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
|
||
(parseStatement "UPDATE users SET age = 'abc'" >>= runStatement testDB)
|
||
`shouldSatisfy` isLeft
|
||
|
||
it "rejects a type mismatch in an ON condition" $ do
|
||
(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
|
||
(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
|
||
(parseStatement "UPDATE users SET nope = 1" >>= runStatement emptyDB)
|
||
`shouldSatisfy` isLeft
|
||
|
||
it "currently allows an INSERT that leaves a column out (known gap)" $ do
|
||
(parseStatement "INSERT INTO users (name) VALUES ('Eve')" >>= runStatement testDB)
|
||
`shouldSatisfy` isRight
|
||
|
||
it "says which clause is wrong and which columns are available" $ do
|
||
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 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 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 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 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
|
||
(parseStatement "SELECT * FROM users u JOIN orders o ON u.id = o.user_id" >>= runStatement testDB)
|
||
`shouldSatisfy` isRight
|
||
|
||
describe "ChuSQL.Core.Engine.Algebra.Op" $ do
|
||
it "renders a plan as indented text" $ do
|
||
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 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)
|
||
`shouldBe` [ "Project [\"u.name\"]"
|
||
, " Join on Eq (Col \"u.id\") (Col \"o.user_id\")"
|
||
, " Scan Just \"u\" \"users\""
|
||
, " Scan Just \"o\" \"orders\""
|
||
]
|
||
|
||
describe "ChuSQL.Core.Engine.Storage.IPC" $ do
|
||
it "projects scan columns and preserves constant rows" $ do
|
||
withTestSession $ \srv -> withServerEnv srv $ do
|
||
runIPCStorage (insertMany "projected" [[("id", VInt 1), ("name", VStr "one")], [("id", VInt 2), ("name", VNull)]]) `shouldReturn` Right ()
|
||
runIPCStorage (scanColumns "projected" ["name"]) `shouldReturn` Right [[("name", VStr "one")], [("name", VNull)]]
|
||
runIPCStorage (scanColumns "projected" []) `shouldReturn` Right [[], []]
|
||
-- query:解析并执行一条 SQL
|
||
let query sql = case parseStatement sql of
|
||
Left err -> expectationFailure err >> pure (Left err)
|
||
Right stmt -> runIPCStorage (runStatementM stmt)
|
||
result <- query "SELECT 1 FROM projected"
|
||
fmap (map (map snd)) result `shouldBe` Right [[VInt 1], [VInt 1]]
|
||
query "SELECT p.name FROM projected p ORDER BY p.id DESC" `shouldReturn` Right [[("p.name", VNull)], [("p.name", VStr "one")]]
|
||
|
||
it "recreates a dropped column without resurrecting old values" $ do
|
||
withTestSession $ \srv -> withServerEnv srv $ do
|
||
-- query:解析并执行一条 SQL
|
||
let query sql = case parseStatement sql of
|
||
Left err -> expectationFailure err >> pure (Left err)
|
||
Right stmt -> runIPCStorage (runStatementM stmt)
|
||
mapM_ (\sql -> query sql `shouldReturn` Right [])
|
||
[ "CREATE TABLE reused (id int, secret str)"
|
||
, "INSERT INTO reused (id, secret) VALUES (1, 'old')"
|
||
, "ALTER TABLE reused DROP COLUMN secret"
|
||
, "ALTER TABLE reused ADD COLUMN secret str DEFAULT 'new'"
|
||
]
|
||
query "SELECT secret FROM reused" `shouldReturn` Right [[("secret", VStr "new")]]
|
||
query "SELECT secret FROM reused WHERE id = 1" `shouldReturn` Right [[("secret", VStr "new")]]
|
||
|
||
it "scan returns rows with all three value types after insert" $ do
|
||
withTestSession $ \srv -> withServerEnv srv $ do
|
||
let row = [("id", VInt 7), ("name", VStr "Alice"), ("flag", VBool True)]
|
||
runIPCStorage (insert "users" row) `shouldReturn` Right ()
|
||
rows <- runIPCStorage (scan "users")
|
||
(map sortRow <$> rows) `shouldBe` Right [sortRow row]
|
||
|
||
it "scan on a missing table returns Left" $ do
|
||
withTestSession $ \srv -> withServerEnv srv $ do
|
||
result <- runIPCStorage (scan "no_such_table")
|
||
result `shouldSatisfy` isLeft
|
||
|
||
it "scan returns all 20 inserted rows in order" $ do
|
||
withTestSession $ \srv -> withServerEnv srv $ do
|
||
mapM_ (\i -> runIPCStorage (insert "many" [("id", VInt i)])) [1 .. 20 :: Int]
|
||
rows <- runIPCStorage (scan "many")
|
||
fmap length rows `shouldBe` Right 20
|
||
fmap (sortOn show . map (lookup "id")) rows
|
||
`shouldBe` Right (sortOn show (map (Just . VInt) [1 .. 20]))
|
||
|
||
it "replaceAll leaves only the new rows" $ do
|
||
withTestSession $ \srv -> withServerEnv srv $ do
|
||
runIPCStorage (insert "t" [("id", VInt 1)]) `shouldReturn` Right ()
|
||
runIPCStorage (replaceAll "t" [[("id", VInt 9)], [("id", VInt 8)]]) `shouldReturn` Right ()
|
||
rows <- runIPCStorage (scan "t")
|
||
fmap (sortOn show . map (lookup "id")) rows
|
||
`shouldBe` Right (sortOn show [Just (VInt 9), Just (VInt 8)])
|
||
|
||
it "lookupByColumn finds a row inserted with an id" $ do
|
||
withTestSession $ \srv -> withServerEnv srv $ do
|
||
let row = [("id", VInt 7), ("name", VStr "Zoe")]
|
||
runIPCStorage (insert "keyed" row) `shouldReturn` Right ()
|
||
found <- runIPCStorage (lookupByColumn "keyed" "id" (VInt 7))
|
||
fmap sortRow (indexRow found) `shouldBe` Just (sortRow row)
|
||
missing <- runIPCStorage (lookupByColumn "keyed" "id" (VInt 8))
|
||
missing `shouldBe` Right (IndexRows [])
|
||
|
||
it "looks up a string index" $ do
|
||
withTestSession $ \srv -> withServerEnv srv $ do
|
||
runIPCStorage (insert "names" [("id", VInt 1), ("name", VStr "alice")]) `shouldReturn` Right ()
|
||
runIPCStorage (insert "names" [("id", VInt 2), ("name", VStr "bob")]) `shouldReturn` Right ()
|
||
runIPCStorage (createIndex "names" "name") `shouldReturn` Right ()
|
||
found <- runIPCStorage (lookupByColumn "names" "name" (VStr "bob"))
|
||
fmap (lookup "id") (indexRow found) `shouldBe` Just (Just (VInt 2))
|
||
missing <- runIPCStorage (lookupByColumn "names" "name" (VStr "dave"))
|
||
missing `shouldBe` Right (IndexRows [])
|
||
|
||
it "range scans an indexed column" $ do
|
||
withTestSession $ \srv -> withServerEnv srv $ do
|
||
runIPCStorage (insert "rng" [("id", VInt 1), ("code", VInt 100)]) `shouldReturn` Right ()
|
||
runIPCStorage (insert "rng" [("id", VInt 2), ("code", VInt 200)]) `shouldReturn` Right ()
|
||
runIPCStorage (insert "rng" [("id", VInt 3), ("code", VInt 300)]) `shouldReturn` Right ()
|
||
runIPCStorage (createIndex "rng" "code") `shouldReturn` Right ()
|
||
hit <- runIPCStorage (scanRange "rng" "code" (Just (VInt 200, True)) (Just (VInt 300, True)))
|
||
fmap (fmap (map (lookup "id"))) hit `shouldBe` Right (Just [Just (VInt 2), Just (VInt 3)])
|
||
open <- runIPCStorage (scanRange "rng" "code" (Just (VInt 200, False)) (Just (VInt 300, True)))
|
||
fmap (fmap (map (lookup "id"))) open `shouldBe` Right (Just [Just (VInt 3)])
|
||
runIPCStorage (scanRange "rng" "nope" Nothing Nothing) `shouldReturn` Right Nothing
|
||
|
||
it "replaceAll rebuilds the index" $ do
|
||
withTestSession $ \srv -> withServerEnv srv $ do
|
||
runIPCStorage (insert "rb" [("id", VInt 1)]) `shouldReturn` Right ()
|
||
runIPCStorage (replaceAll "rb" [[("id", VInt 9), ("name", VStr "Zoe")]]) `shouldReturn` Right ()
|
||
old <- runIPCStorage (lookupByColumn "rb" "id" (VInt 1))
|
||
old `shouldBe` Right (IndexRows [])
|
||
new <- runIPCStorage (lookupByColumn "rb" "id" (VInt 9))
|
||
fmap sortRow (indexRow new)
|
||
`shouldBe` Just (sortRow [("id", VInt 9), ("name", VStr "Zoe")])
|
||
|
||
it "snapshot lists tables and scans each one to build the database" $ do
|
||
withTestSession $ \srv -> withServerEnv srv $ do
|
||
runIPCStorage (insert "a" [("id", VInt 1)]) `shouldReturn` Right ()
|
||
runIPCStorage (insert "b" [("id", VInt 2)]) `shouldReturn` Right ()
|
||
db <- runIPCStorage snapshot
|
||
map fst db `shouldBe` ["a", "b"]
|
||
map (length . tableRows . snd) db `shouldBe` [1, 1]
|
||
map (map (lookup "id") . tableRows . snd) db `shouldBe` [[Just (VInt 1)], [Just (VInt 2)]]
|
||
|
||
it "doListTables returns the tables that exist" $ do
|
||
withTestSession $ \srv -> withServerEnv srv $ do
|
||
runIPCStorage (insert "t1" [("id", VInt 1)]) `shouldReturn` Right ()
|
||
runIPCStorage (insert "t2" [("id", VInt 2)]) `shouldReturn` Right ()
|
||
doListTables `shouldReturn` Right ["t1", "t2"]
|
||
|
||
it "rows survive a restart on the same data directory" $ do
|
||
withTestSession $ \srv -> do
|
||
withServerEnv srv $ runIPCStorage (insert "persist" [("id", VInt 42)]) `shouldReturn` Right ()
|
||
closeConnection
|
||
openSessionIn (dataDir srv)
|
||
rows <- withServerEnv srv $ runIPCStorage (scan "persist")
|
||
(map sortRow <$> rows) `shouldBe` Right [sortRow [("id", VInt 42)]]
|
||
|
||
it "an unusable config path yields Left without throwing" $ do
|
||
opened <- localStorageLink (Just "no/such/dir/chusql-core-storage.toml")
|
||
case opened of
|
||
Left err -> err `shouldSatisfy` (not . null)
|
||
Right _ -> expectationFailure "opening a missing config file should fail"
|
||
|
||
it "without a link the storage layer reports Left instead of throwing" $ do
|
||
closeConnection
|
||
result <- runIPCStorage (scan "users")
|
||
result `shouldSatisfy` isLeft
|
||
it "INSERT then SELECT WHERE id = k" $ do
|
||
withTestSession $ \srv -> withServerEnv srv $ do
|
||
_ <-
|
||
runIPCStorage
|
||
( runStatementM
|
||
(CreateTable "ipc_idx" [("id", TInt), ("name", TStr)])
|
||
)
|
||
_ <-
|
||
runIPCStorage
|
||
( runStatementM
|
||
(Insert "ipc_idx" ["id", "name"] [[LitInt 42, LitStr "Zoe"]])
|
||
)
|
||
result <-
|
||
runIPCStorage
|
||
( runStatementM
|
||
( Select
|
||
{ selectCols = ["name"]
|
||
, selectFrom = FromTable Nothing "ipc_idx"
|
||
, selectWhere = Just (Eq (Col "id") (LitInt 42))
|
||
, selectGroupBy = []
|
||
, selectOrderBy = []
|
||
, selectLimit = Nothing
|
||
}
|
||
)
|
||
)
|
||
result `shouldBe` Right [[("name", VStr "Zoe")]]
|
||
-- 存储层的日志现在直接打在本进程的输出里,没有单独的 debug.log 可读;
|
||
-- 「走索引而不是全表扫」由下面 CREATE INDEX 那组用例与 Rust 侧 runtime 测试覆盖。
|
||
|
||
it "CREATE INDEX makes a query on that column use the index" $ do
|
||
withTestSession $ \srv -> withServerEnv srv $ do
|
||
_ <- runIPCStorage (runStatementM (CreateTable "ix_t" [("id", TInt), ("code", TInt)]))
|
||
_ <-
|
||
runIPCStorage
|
||
( runStatementM
|
||
(Insert "ix_t" ["id", "code"] [[LitInt 1, LitInt 500], [LitInt 2, LitInt 501]])
|
||
)
|
||
|
||
withoutIndex <- runIPCStorage (lookupByColumn "ix_t" "code" (VInt 501))
|
||
withoutIndex `shouldBe` Right NoIndex
|
||
scanned <- runIPCStorage (runStatementM (makeSelect ["code"] "ix_t" (Just (Eq (Col "code") (LitInt 501)))))
|
||
scanned `shouldBe` Right [[("code", VInt 501)]]
|
||
|
||
created <- runIPCStorage (runStatementM (CreateIndex "ix_t" "code"))
|
||
created `shouldBe` Right []
|
||
withIndex <- runIPCStorage (lookupByColumn "ix_t" "code" (VInt 501))
|
||
fmap sortRow (indexRow withIndex)
|
||
`shouldBe` Just (sortRow [("id", VInt 2), ("code", VInt 501)])
|
||
indexed <- runIPCStorage (runStatementM (makeSelect ["code"] "ix_t" (Just (Eq (Col "code") (LitInt 501)))))
|
||
indexed `shouldBe` Right [[("code", VInt 501)]]
|
||
|
||
_ <- runIPCStorage (runStatementM (Insert "ix_t" ["id", "code"] [[LitInt 3, LitInt 502]]))
|
||
fresh <- runIPCStorage (lookupByColumn "ix_t" "code" (VInt 502))
|
||
fmap sortRow (indexRow fresh)
|
||
`shouldBe` Just (sortRow [("id", VInt 3), ("code", VInt 502)])
|
||
|
||
it "schema carries the row count, index list and column statistics" $ do
|
||
withTestSession $ \srv -> withServerEnv srv $ do
|
||
_ <- runIPCStorage (runStatementM (CreateTable "meta_t" [("id", TInt), ("code", TInt)]))
|
||
_ <-
|
||
runIPCStorage
|
||
( runStatementM
|
||
(Insert "meta_t" ["id", "code"] [[LitInt 1, LitInt 500], [LitInt 2, LitInt 501]])
|
||
)
|
||
_ <- runIPCStorage (runStatementM (CreateIndex "meta_t" "code"))
|
||
db <- runIPCStorage schema
|
||
case lookup "meta_t" db of
|
||
Nothing -> expectationFailure "meta_t missing from schema"
|
||
Just t -> do
|
||
fmap metaRowCount (tableMeta t) `shouldBe` Just 2
|
||
fmap metaIndexes (tableMeta t) `shouldBe` Just ["id", "code"]
|
||
fmap (sortOn fst . map (\(c, d, _) -> (c, d)) . metaDistinct) (tableMeta t)
|
||
`shouldBe` Just [("code", 2), ("id", 2)]
|
||
|
||
it "CREATE INDEX accepts duplicates and returns every matching row" $ do
|
||
withTestSession $ \srv -> withServerEnv srv $ do
|
||
_ <- runIPCStorage (runStatementM (CreateTable "dup_ix" [("id", TInt), ("age", TInt)]))
|
||
_ <-
|
||
runIPCStorage
|
||
( runStatementM
|
||
(Insert "dup_ix" ["id", "age"] [[LitInt 1, LitInt 30], [LitInt 2, LitInt 30]])
|
||
)
|
||
res <- runIPCStorage (runStatementM (CreateIndex "dup_ix" "age"))
|
||
res `shouldBe` Right []
|
||
found <- runIPCStorage (lookupByColumn "dup_ix" "age" (VInt 30))
|
||
case found of
|
||
Right (IndexRows rows) -> map (lookup "id") rows `shouldBe` [Just (VInt 1), Just (VInt 2)]
|
||
_ -> expectationFailure "expected an indexed lookup with rows"
|
||
|
||
it "DELETE removes only the matching rows" $ do
|
||
withTestSession $ \srv -> withServerEnv srv $ do
|
||
_ <- runIPCStorage (runStatementM (CreateTable "del_t" [("id", TInt), ("name", TStr)]))
|
||
_ <-
|
||
runIPCStorage
|
||
( runStatementM
|
||
( Insert
|
||
"del_t"
|
||
["id", "name"]
|
||
[[LitInt 1, LitStr "A"], [LitInt 2, LitStr "B"], [LitInt 3, LitStr "C"]]
|
||
)
|
||
)
|
||
_ <- runIPCStorage (runStatementM (Delete "del_t" (Just (Eq (Col "id") (LitInt 2)))))
|
||
rows <- runIPCStorage (runStatementM (makeSelect ["name"] "del_t" Nothing))
|
||
rows `shouldBe` Right [[("name", VStr "A")], [("name", VStr "C")]]
|
||
gone <- runIPCStorage (lookupByColumn "del_t" "id" (VInt 2))
|
||
gone `shouldBe` Right (IndexRows [])
|
||
|
||
it "multi-row INSERT goes in one statement" $ do
|
||
withTestSession $ \srv -> withServerEnv srv $ do
|
||
_ <- runIPCStorage (runStatementM (CreateTable "many_t" [("id", TInt)]))
|
||
_ <-
|
||
runIPCStorage
|
||
( runStatementM
|
||
(Insert "many_t" ["id"] [[LitInt i] | i <- [1 .. 50]])
|
||
)
|
||
rows <- runIPCStorage (runStatementM (makeSelect ["id"] "many_t" Nothing))
|
||
fmap length rows `shouldBe` Right 50
|
||
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 with BOOL column" $ do
|
||
parseStatement "CREATE TABLE t (flag BOOL)"
|
||
`shouldBe` Right (CreateTable "t" [("flag", TBool)])
|
||
|
||
it "parses TRUE / FALSE as boolean literals (case-insensitive)" $ do
|
||
parseStatement "INSERT INTO t (flag) VALUES (TRUE)"
|
||
`shouldBe` Right (Insert "t" ["flag"] [[LitBool True]])
|
||
parseStatement "INSERT INTO t (flag) VALUES (false)"
|
||
`shouldBe` Right (Insert "t" ["flag"] [[LitBool False]])
|
||
|
||
it "boolean literals work in WHERE (and don't need a column named true)" $ do
|
||
parseStatement "SELECT * FROM t WHERE flag = TRUE"
|
||
`shouldBe` Right (makeSelect ["*"] "t" (Just (Eq (Col "flag") (LitBool True))))
|
||
|
||
it "inserts and reads back a boolean column end-to-end" $ do
|
||
let db = [("t", Table "t" [("id", TInt), ("flag", TBool)] [] Nothing)]
|
||
case runStatement db (Insert "t" ["id", "flag"] [[LitInt 1, LitBool True]]) of
|
||
Left e -> expectationFailure e
|
||
Right (db', _) ->
|
||
rowsOf (runStatement db' (makeSelect ["flag"] "t" Nothing))
|
||
`shouldBe` Right [[("flag", VBool True)]]
|
||
|
||
it "parses CREATE TABLE with VARCHAR as TStr" $ do
|
||
parseStatement "CREATE TABLE t (name VARCHAR)"
|
||
`shouldBe` Right (CreateTable "t" [("name", TStr)])
|
||
|
||
it "rejects CREATE TABLE with missing type" $ do
|
||
parseStatement "CREATE TABLE t (id)"
|
||
`shouldSatisfy` isLeft
|
||
|
||
describe "ChuSQL.Core.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
|
||
|
||
describe "ChuSQL.Core.Engine (NULL)" $ do
|
||
-- rows:在给定库上跑 SQL 取结果行
|
||
let rows db input = parseStatement input >>= rowsOf . runStatement db
|
||
-- vals:只取结果里的值
|
||
vals db input = fmap (map (map snd)) (rows db input)
|
||
seeded = either (const testDB) fst (parseStatement "INSERT INTO users (id, name) VALUES (9, 'Nine')" >>= runStatement testDB)
|
||
|
||
it "parses IS NULL and IS NOT NULL" $ do
|
||
parseStatement "SELECT name FROM users WHERE age IS NULL"
|
||
`shouldBe` Right (makeSelect ["name"] "users" (Just (IsNull (Col "age"))))
|
||
parseStatement "SELECT name FROM users WHERE age IS NOT NULL"
|
||
`shouldBe` Right (makeSelect ["name"] "users" (Just (IsNotNull (Col "age"))))
|
||
|
||
it "fills omitted columns with NULL" $ do
|
||
rows seeded "SELECT name FROM users WHERE age IS NULL"
|
||
`shouldBe` Right [[("name", VStr "Nine")]]
|
||
|
||
it "IS NOT NULL drops NULL rows" $ do
|
||
vals seeded "SELECT name FROM users WHERE age IS NOT NULL"
|
||
`shouldBe` Right [[VStr "Alice"], [VStr "Bob"], [VStr "Carol"]]
|
||
|
||
it "compares nothing with NULL" $ do
|
||
vals seeded "SELECT name FROM users WHERE age = NULL" `shouldBe` Right []
|
||
vals seeded "SELECT name FROM users WHERE NULL IS NULL" `shouldBe` Right [[VStr "Alice"], [VStr "Bob"], [VStr "Carol"], [VStr "Nine"]]
|
||
|
||
it "keeps NULL through three-valued AND/OR" $ do
|
||
vals seeded "SELECT name FROM users WHERE age > 18 OR age IS NULL"
|
||
`shouldBe` Right [[VStr "Alice"], [VStr "Carol"], [VStr "Nine"]]
|
||
vals seeded "SELECT name FROM users WHERE age > 18 AND age < 30"
|
||
`shouldBe` Right [[VStr "Alice"]]
|
||
|
||
it "propagates NULL through arithmetic and projection" $ do
|
||
vals seeded "SELECT age + 1 FROM users WHERE name = 'Nine'" `shouldBe` Right [[VNull]]
|
||
vals seeded "SELECT age * 2 FROM users WHERE name = 'Nine'" `shouldBe` Right [[VNull]]
|
||
|
||
describe "ChuSQL.Core.Engine (TYPES)" $ do
|
||
-- vals:在给定库上跑 SQL 只取值
|
||
let vals db input = fmap (map (map snd)) (parseStatement input >>= rowsOf . runStatement db)
|
||
-- withTable:先建表,返回新库
|
||
withTable ddl = either (const testDB) fst (parseStatement ddl >>= runStatement testDB)
|
||
|
||
it "parses every column type" $ do
|
||
parseStatement "CREATE TABLE t (a bigint, b smallint, c varchar(5), d char(2), e float, f double, g decimal(6,2), h date, i timestamp, j blob, k bool)"
|
||
`shouldBe` Right
|
||
( CreateTable
|
||
"t"
|
||
[ ("a", plainColumn CBigInt)
|
||
, ("b", plainColumn CSmallInt)
|
||
, ("c", plainColumn (CVarchar 5))
|
||
, ("d", plainColumn (CChar 2))
|
||
, ("e", plainColumn CFloat)
|
||
, ("f", plainColumn CDouble)
|
||
, ("g", plainColumn (CDecimal 6 2))
|
||
, ("h", plainColumn CDate)
|
||
, ("i", plainColumn CTimestamp)
|
||
, ("j", plainColumn CBlob)
|
||
, ("k", plainColumn CBool)
|
||
]
|
||
)
|
||
parseStatement "CREATE TABLE t (a nope)" `shouldSatisfy` isLeft
|
||
|
||
it "reads and writes float literals" $ do
|
||
vals testDB "SELECT 1.5 + 1" `shouldBe` Right [[VFloat 2.5]]
|
||
vals testDB "SELECT 1 / 2, 1.0 / 2" `shouldBe` Right [[VInt 0, VFloat 0.5]]
|
||
|
||
it "stores dates, timestamps and blobs as validated strings" $ do
|
||
let db = withTable "CREATE TABLE typed (id int, d date, ts timestamp, p blob)"
|
||
case parseStatement "INSERT INTO typed (id, d, ts, p) VALUES (1, DATE '2024-01-02', TIMESTAMP '2024-01-02 03:04:05', X'4869')" >>= runStatement db of
|
||
Left e -> expectationFailure e
|
||
Right (db', _) -> do
|
||
vals db' "SELECT d FROM typed" `shouldBe` Right [[VStr "2024-01-02"]]
|
||
vals db' "SELECT p FROM typed" `shouldBe` Right [[VStr "4869"]]
|
||
runStatement db (Insert "typed" ["d"] [[LitStr "not-a-date"]])
|
||
`shouldSatisfy` either (isInfixOf "date") (const False)
|
||
|
||
it "rejects values that do not fit the column" $ do
|
||
let db = withTable "CREATE TABLE sized (id int, code varchar(3), d date)"
|
||
runStatement db (Insert "sized" ["code"] [[LitStr "toolong"]])
|
||
`shouldSatisfy` either (isInfixOf "longer") (const False)
|
||
runStatement db (Insert "sized" ["d"] [[LitInt 5]])
|
||
`shouldSatisfy` isLeft
|
||
|
||
describe "ChuSQL.Core.Engine (CONSTRAINTS)" $ do
|
||
-- vals:在给定库上跑 SQL 只取值
|
||
let vals db input = fmap (map (map snd)) (parseStatement input >>= rowsOf . runStatement db)
|
||
-- withTable:先建表,返回新库
|
||
withTable ddl = either (const testDB) fst (parseStatement ddl >>= runStatement testDB)
|
||
-- step:执行一条语句,失败就返回原库
|
||
step db input = either (const db) fst (parseStatement input >>= runStatement db)
|
||
|
||
it "rejects omitting a NOT NULL column without a default" $ do
|
||
let db = withTable "CREATE TABLE nn (id int NOT NULL, name str)"
|
||
runStatement db (Insert "nn" ["name"] [[LitStr "x"]])
|
||
`shouldSatisfy` either (isInfixOf "NOT NULL") (const False)
|
||
runStatement db (Insert "nn" ["id"] [[LitNull]])
|
||
`shouldSatisfy` either (isInfixOf "NOT NULL") (const False)
|
||
|
||
it "fills the DEFAULT when a column is omitted" $ do
|
||
let db = withTable "CREATE TABLE dft (id int, tag str DEFAULT 'x')"
|
||
vals (step db "INSERT INTO dft (id) VALUES (1)") "SELECT tag FROM dft" `shouldBe` Right [[VStr "x"]]
|
||
|
||
it "assigns AUTO_INCREMENT values" $ do
|
||
let db = step (withTable "CREATE TABLE ai (id int AUTO_INCREMENT PRIMARY KEY, name str)") "INSERT INTO ai (name) VALUES ('a'), ('b')"
|
||
vals db "SELECT id FROM ai" `shouldBe` Right [[VInt 1], [VInt 2]]
|
||
|
||
it "rejects duplicates in a UNIQUE column" $ do
|
||
let db = step (withTable "CREATE TABLE uq (id int UNIQUE, name str)") "INSERT INTO uq (id, name) VALUES (1, 'a')"
|
||
runStatement db (Insert "uq" ["id", "name"] [[LitInt 1, LitStr "b"]])
|
||
`shouldSatisfy` either (isInfixOf "UNIQUE") (const False)
|
||
|
||
it "enforces CHECK constraints" $ do
|
||
let db = withTable "CREATE TABLE ck (age int CHECK (age > 0))"
|
||
runStatement db (Insert "ck" ["age"] [[LitInt (-1)]])
|
||
`shouldSatisfy` either (isInfixOf "CHECK") (const False)
|
||
vals (step db "INSERT INTO ck (age) VALUES (5)") "SELECT age FROM ck" `shouldBe` Right [[VInt 5]]
|
||
|
||
describe "ChuSQL.Core.Engine (ALTER TABLE)" $ do
|
||
-- vals:在给定库上跑 SQL 只取值
|
||
let vals db input = fmap (map (map snd)) (parseStatement input >>= rowsOf . runStatement db)
|
||
-- run:在给定库上执行语句
|
||
run db input = parseStatement input >>= runStatement db
|
||
-- withTable:先建表,返回新库
|
||
withTable ddl = either (const testDB) fst (parseStatement ddl >>= runStatement testDB)
|
||
-- step:执行一条语句,失败就返回原库
|
||
step db input = either (const db) fst (run db input)
|
||
|
||
it "ADD COLUMN fills existing rows with the default or NULL" $ do
|
||
let seeded = step (withTable "CREATE TABLE ac (id int)") "INSERT INTO ac (id) VALUES (1)"
|
||
withDefault = step seeded "ALTER TABLE ac ADD COLUMN tag str DEFAULT 'n'"
|
||
withNull = step seeded "ALTER TABLE ac ADD COLUMN note str"
|
||
vals withDefault "SELECT tag FROM ac" `shouldBe` Right [[VStr "n"]]
|
||
vals withNull "SELECT note FROM ac" `shouldBe` Right [[VNull]]
|
||
|
||
it "rejects ADD COLUMN NOT NULL without a default on a non-empty table" $ do
|
||
let seeded = step (withTable "CREATE TABLE ac2 (id int)") "INSERT INTO ac2 (id) VALUES (1)"
|
||
run seeded "ALTER TABLE ac2 ADD COLUMN req int NOT NULL" `shouldSatisfy` isLeft
|
||
vals (step (withTable "CREATE TABLE ac3 (id int)") "ALTER TABLE ac3 ADD COLUMN req int NOT NULL") "SELECT * FROM ac3" `shouldBe` Right []
|
||
|
||
it "RENAME COLUMN moves the data to the new name" $ do
|
||
let seeded = step (withTable "CREATE TABLE rn (id int, name str)") "INSERT INTO rn (id, name) VALUES (1, 'a')"
|
||
renamed = step seeded "ALTER TABLE rn RENAME COLUMN name TO label"
|
||
vals renamed "SELECT label FROM rn" `shouldBe` Right [[VStr "a"]]
|
||
run renamed "SELECT name FROM rn" `shouldSatisfy` isLeft
|
||
run seeded "ALTER TABLE rn RENAME COLUMN nope TO other" `shouldSatisfy` isLeft
|
||
|
||
it "ALTER COLUMN TYPE re-reads the stored values" $ do
|
||
let seeded = step (withTable "CREATE TABLE ty (id int)") "INSERT INTO ty (id) VALUES (7)"
|
||
widened = step seeded "ALTER TABLE ty ALTER COLUMN id TYPE bigint"
|
||
vals widened "SELECT id FROM ty" `shouldBe` Right [[VInt 7]]
|
||
let texty = step (withTable "CREATE TABLE ty2 (name str)") "INSERT INTO ty2 (name) VALUES ('x')"
|
||
run texty "ALTER TABLE ty2 ALTER COLUMN name TYPE int" `shouldSatisfy` isLeft
|
||
|
||
it "ALTER COLUMN SET/DROP DEFAULT changes what omitted columns get" $ do
|
||
let base = withTable "CREATE TABLE df (id int, tag str)"
|
||
withDefault = step base "ALTER TABLE df ALTER COLUMN tag SET DEFAULT 'y'"
|
||
dropped = step withDefault "ALTER TABLE df ALTER COLUMN tag DROP DEFAULT"
|
||
vals (step withDefault "INSERT INTO df (id) VALUES (1)") "SELECT tag FROM df" `shouldBe` Right [[VStr "y"]]
|
||
vals (step dropped "INSERT INTO df (id) VALUES (2)") "SELECT tag FROM df" `shouldBe` Right [[VNull]]
|
||
|
||
it "ALTER COLUMN SET/DROP NOT NULL checks the stored rows" $ do
|
||
let nulls = step (withTable "CREATE TABLE nl (id int, tag str)") "INSERT INTO nl (id) VALUES (1)"
|
||
strict = step (withTable "CREATE TABLE nl2 (id int, tag str NOT NULL)") "INSERT INTO nl2 (id, tag) VALUES (1, 'a')"
|
||
run nulls "ALTER TABLE nl ALTER COLUMN tag SET NOT NULL" `shouldSatisfy` isLeft
|
||
run nulls "ALTER TABLE nl ALTER COLUMN tag DROP NOT NULL" `shouldSatisfy` isRight
|
||
run strict "ALTER TABLE nl2 ALTER COLUMN tag DROP NOT NULL" `shouldSatisfy` isRight
|
||
|
||
describe "ChuSQL.Core.Model (值语义唯一权威)" $ do
|
||
it "treats an integral float and an int as the same value" $ do
|
||
valuesEqual (VInt 1) (VFloat 1.0) `shouldBe` True
|
||
compareValue (VInt 1) (VFloat 1.0) `shouldBe` EQ
|
||
hash (VInt 1) `shouldBe` hash (VFloat 1.0)
|
||
hashValue (VInt 1) `shouldBe` hashValue (VFloat 1.0)
|
||
|
||
it "keeps null, text and boolean in their own buckets" $ do
|
||
hash VNull `shouldNotBe` hash (VInt 0)
|
||
hash (VInt 1) `shouldNotBe` hash (VStr "1")
|
||
hash (VStr "a") `shouldNotBe` hash (VBool True)
|
||
|
||
it "orders null below every value and compares across families" $ do
|
||
compareValue VNull (VInt minBound) `shouldBe` LT
|
||
compareValue (VInt maxBound) VNull `shouldBe` GT
|
||
compareValue VNull VNull `shouldBe` EQ
|
||
compareValue (VInt 1) (VStr "a") `shouldBe` LT
|
||
compareValue (VStr "a") (VBool True) `shouldBe` LT
|
||
compareValue (VBool True) VNull `shouldBe` GT
|
||
|
||
it "propagates null through comparisons" $ do
|
||
evalExpr (Eq (Col "v") LitNull) [("v", VInt 1)] `shouldBe` Right VNull
|
||
evalExpr (Gt LitNull (LitInt 1)) [] `shouldBe` Right VNull
|
||
|
||
it "uses one comparability rule and one canonical form" $ do
|
||
comparableTypes CInt CFloat `shouldBe` True
|
||
assignable CInt CFloat `shouldBe` True
|
||
assignable CStr CInt `shouldBe` False
|
||
comparableTypes CInt CStr `shouldBe` False
|
||
comparableTypes CStr CDate `shouldBe` True
|
||
canonicalValue (VFloat 3.0) `shouldBe` VInt 3
|
||
canonicalValue (VFloat 3.5) `shouldBe` VFloat 3.5
|
||
|
||
describe "比较语义统一(排序 / 聚合 / 哈希连接)" $ do
|
||
-- vals:在给定库上跑 SQL 只取值
|
||
let vals db input = fmap (map (map snd)) (parseStatement input >>= rowsOf . runStatement db)
|
||
-- seed:建一张两列空表
|
||
seed name = case parseStatement ("CREATE TABLE " ++ name ++ " (id int, v int)") >>= runStatement emptyDB of
|
||
Left e -> error e
|
||
Right (db, _) -> db
|
||
-- ins:插入一行,失败直接判测试失败
|
||
ins db input = case parseStatement input >>= runStatement db of
|
||
Left e -> error e
|
||
Right (db', _) -> db'
|
||
rows3 =
|
||
ins
|
||
(ins (ins (seed "m") "INSERT INTO m (id, v) VALUES (1, 2)") "INSERT INTO m (id, v) VALUES (2, 1)")
|
||
"INSERT INTO m (id, v) VALUES (3, 2)"
|
||
withNull =
|
||
ins
|
||
(ins (ins (seed "m2") "INSERT INTO m2 (id, v) VALUES (1, 2)") "INSERT INTO m2 (id) VALUES (2)")
|
||
"INSERT INTO m2 (id, v) VALUES (3, 1)"
|
||
|
||
it "sorts with the same rule that aggregates use" $ do
|
||
map (map snd) (sortRows [("v", Asc)] [[("v", VFloat 1.5)], [("v", VNull)], [("v", VInt 1)]])
|
||
`shouldBe` [[VNull], [VInt 1], [VFloat 1.5]]
|
||
vals rows3 "SELECT v FROM m ORDER BY v, id" `shouldBe` Right [[VInt 1], [VInt 2], [VInt 2]]
|
||
vals rows3 "SELECT v FROM m ORDER BY v DESC, id" `shouldBe` Right [[VInt 2], [VInt 2], [VInt 1]]
|
||
vals rows3 "SELECT MIN(v), MAX(v) FROM m" `shouldBe` Right [[VInt 1, VInt 2]]
|
||
|
||
it "sorts the catalog names that are unknown into one code" $ do
|
||
errorCode "unknown database: d" `shouldBe` "not_found"
|
||
errorCode "unknown role: r" `shouldBe` "not_found"
|
||
errorCode "unknown account" `shouldBe` "not_found"
|
||
|
||
it "puts null first when sorting and skips it in aggregates" $ do
|
||
vals withNull "SELECT v FROM m2 ORDER BY v" `shouldBe` Right [[VNull], [VInt 1], [VInt 2]]
|
||
vals withNull "SELECT MIN(v), MAX(v), SUM(v), COUNT(v), COUNT(*) FROM m2"
|
||
`shouldBe` Right [[VInt 1, VInt 2, VInt 3, VInt 2, VInt 3]]
|
||
|
||
it "uses 1 and 1.0 as one key in predicates and unique columns" $ do
|
||
let one = ins (seed "f1") "INSERT INTO f1 (id, v) VALUES (1, 1)"
|
||
vals one "SELECT id FROM f1 WHERE v = 1.0" `shouldBe` Right [[VInt 1]]
|
||
vals one "SELECT id FROM f1 WHERE v = 1" `shouldBe` Right [[VInt 1]]
|
||
let uniqueTable = case parseStatement "CREATE TABLE uq (id int, v int UNIQUE)" >>= runStatement emptyDB of
|
||
Left e -> error e
|
||
Right (db, _) -> db
|
||
seeded = ins uniqueTable "INSERT INTO uq (id, v) VALUES (1, 1)"
|
||
case parseStatement "INSERT INTO uq (id, v) VALUES (2, 1.0)" >>= runStatement seeded of
|
||
Left err -> err `shouldBe` "UNIQUE: duplicate value in column v"
|
||
Right _ -> expectationFailure "1.0 must clash with 1 on a unique column"
|
||
|
||
describe "ChuSQL.Core.Engine.Builtin (解析与调用唯一入口)" $ do
|
||
it "resolves aggregate names case-insensitively" $ do
|
||
resolveBuiltin "COUNT" `shouldBe` Just BCount
|
||
resolveBuiltin "Sum" `shouldBe` Just BSum
|
||
resolveBuiltin "median" `shouldBe` Nothing
|
||
map fst builtinNames `shouldBe` ["count", "sum", "avg", "min", "max"]
|
||
|
||
it "invokes every aggregate from one place" $ do
|
||
invokeBuiltin BCountAll 3 [] `shouldBe` Right (VInt 3)
|
||
invokeBuiltin BCount 3 [VInt 1, VNull, VInt 2] `shouldBe` Right (VInt 2)
|
||
invokeBuiltin BSum 0 [] `shouldBe` Right VNull
|
||
invokeBuiltin BSum 3 [VInt 1, VNull, VInt 2] `shouldBe` Right (VInt 3)
|
||
invokeBuiltin BAvg 2 [VInt 1, VInt 3] `shouldBe` Right (VFloat 2)
|
||
invokeBuiltin BMin 2 [VNull, VInt 4] `shouldBe` Right (VInt 4)
|
||
invokeBuiltin BMax 2 [VNull, VInt 4] `shouldBe` Right (VInt 4)
|
||
|
||
it "maps names to syntax nodes in one table" $ do
|
||
builtinOfExpr CountAll `shouldBe` Just BCountAll
|
||
builtinNode BCount (Col "age") `shouldBe` CountOf (Col "age")
|
||
builtinNode BAvg (Col "age") `shouldBe` AvgOf (Col "age")
|
||
aggregateArg (SumOf (Col "age")) `shouldBe` Just (Col "age")
|
||
aggregateArg CountAll `shouldBe` Nothing
|
||
unsupportedAggregate `shouldBe` "only COUNT, SUM, AVG, MIN and MAX can be aggregated"
|
||
|
||
it "exposes one operator table and one executor" $ do
|
||
operatorSymbol <$> operatorOfExpr (Add (LitInt 1) (LitInt 2)) `shouldBe` Just "+"
|
||
operatorSymbol <$> operatorOfExpr (And (LitBool True) (LitBool False)) `shouldBe` Just "AND"
|
||
executeOperator OpAdd [VInt 1, VInt 2] `shouldBe` Right (VInt 3)
|
||
executeOperator OpDiv [VInt 1, VInt 0] `shouldBe` Left "division by zero"
|
||
executeOperator OpNeg [VFloat 2] `shouldBe` Right (VFloat (-2))
|
||
executeOperator OpEq [VInt 1, VFloat 1.0] `shouldBe` Right (VBool True)
|
||
executeOperator OpGt [VNull, VInt 1] `shouldBe` Right VNull
|
||
executeOperator OpAnd [VBool True, VNull] `shouldBe` Right VNull
|
||
executeOperator OpOr [VBool True, VNull] `shouldBe` Right (VBool True)
|
||
executeOperator OpAdd [VInt 1] `shouldSatisfy` isLeft
|
||
executeOperator OpNeg [VInt 1, VInt 2] `shouldSatisfy` isLeft
|
||
|
||
it "keeps null comparisons three-valued" $ do
|
||
inValues VNull [VInt 1] `shouldBe` Right VNull
|
||
inValues (VInt 1) [VInt 1, VNull] `shouldBe` Right (VBool True)
|
||
inValues (VInt 2) [VInt 1, VNull] `shouldBe` Right VNull
|
||
threeValuedNot VNull `shouldBe` VNull
|
||
threeValuedNot (VBool True) `shouldBe` VBool False
|
||
|
||
describe "ChuSQL.Core.Engine.Parallel (分片调度与分片选择)" $ do
|
||
it "keeps the submission order of finished jobs" $ do
|
||
pool <- workerPool 4
|
||
results <- poolRun pool [delayed 30000 (1 :: Int), delayed 1000 2, delayed 20000 3]
|
||
results `shouldBe` [1, 2, 3]
|
||
|
||
it "dispatches every job without waiting for the slow one" $ do
|
||
pool <- workerPool 4
|
||
finished <- timeout 800000 (poolRun pool (replicate 4 (delayed 300000 ())))
|
||
finished `shouldBe` Just (replicate 4 ())
|
||
|
||
it "rethrows job failures in the submission order" $ do
|
||
pool <- workerPool 4
|
||
let failed = error "first bad job" :: IO ()
|
||
later = error "second bad job" :: IO ()
|
||
outcome <- try (poolRun pool (pure () : failed : [later])) :: IO (Either ErrorCall [()])
|
||
case outcome of
|
||
Left err -> show err `shouldContain` "first bad job"
|
||
Right _ -> expectationFailure "poolRun returned instead of rethrowing the job failure"
|
||
|
||
it "scans a small table in one sequential request" $ do
|
||
probe <- newProbe users 4
|
||
let op = Scan Nothing "users" Nothing
|
||
runProbeStorage (evalRelOpM testDB op) probe `shouldReturn` evalRelOp testDB op
|
||
recordedCalls probe `shouldReturn` ["scan:users"]
|
||
|
||
it "splits a large table into the available shards" $ do
|
||
probe <- newProbe bigTable 4
|
||
let op = Scan Nothing "big" Nothing
|
||
runProbeStorage (evalRelOpM bigDB op) probe `shouldReturn` evalRelOp bigDB op
|
||
recordedCalls probe `shouldReturn` ["scan_shards:4:big"]
|
||
|
||
it "projects columns through the shard path" $ do
|
||
probe <- newProbe bigTable 3
|
||
let op = Scan Nothing "big" (Just ["name"])
|
||
runProbeStorage (evalRelOpM bigDB op) probe `shouldReturn` evalRelOp bigDB op
|
||
recordedCalls probe `shouldReturn` ["scan_shards:3:big"]
|
||
|
||
it "reads shards of one table through the wire" $ do
|
||
withTestSession $ \srv -> withServerEnv srv $ do
|
||
let rows = [[("id", VInt i), ("name", VStr ("n" ++ show i))] | i <- [1 .. 400 :: Int]]
|
||
runIPCStorage (insertMany "wide" rows) `shouldReturn` Right ()
|
||
whole <- runIPCStorage (scan "wide")
|
||
fmap length whole `shouldBe` Right 400
|
||
shardRows <- runIPCStorage (scanShards 4 "wide" Nothing)
|
||
shardRows `shouldBe` whole
|
||
projected <- runIPCStorage (scanShards 4 "wide" (Just ["name"]))
|
||
fmap (map (map fst)) projected `shouldBe` Right (map (const ["name"]) rows)
|
||
fmap (concatMap (map snd)) projected
|
||
`shouldBe` Right (map (VStr . ("n" ++) . show) [1 .. 400 :: Int])
|
||
|
||
describe "ChuSQL.Core.Engine.Error (错误分类唯一入口)" $ do
|
||
it "maps messages to wire codes without changing them" $ do
|
||
errorCode "no database selected" `shouldBe` "no_database"
|
||
errorCode "administrator required" `shouldBe` "forbidden"
|
||
errorCode "unknown table: t" `shouldBe` "not_found"
|
||
errorCode "unknown column: c" `shouldBe` "query_error"
|
||
errorCode "type error: expected two numbers" `shouldBe` "query_error"
|
||
errorCode "something else" `shouldBe` "query_error"
|
||
|
||
it "keeps the account service codes in the same module" $ do
|
||
accountErrorCode "account already exists" `shouldBe` "conflict"
|
||
accountErrorCode "unknown account" `shouldBe` "not_found"
|
||
accountErrorCode "disk gone" `shouldBe` "storage_error"
|
||
|
||
it "classifies the same messages into categories" $ do
|
||
errorCategory "no database selected" `shouldBe` NoDatabaseError
|
||
errorCategory "administrator required" `shouldBe` PermissionError
|
||
errorCategory "unknown table: t" `shouldBe` CatalogError
|
||
errorCategory "type error: expected two numbers" `shouldBe` TypeError
|
||
errorCategory "storage library call failed: x" `shouldBe` StorageError
|
||
errorCategory "unsupported protocol version" `shouldBe` ProtocolError
|
||
errorCategory "something else" `shouldBe` UnknownError
|
||
|
||
-- | 每个测试表都清空行后的库
|
||
emptyDB :: Database
|
||
emptyDB = [(n, t{tableRows = []}) | (n, t) <- testDB]
|
||
|
||
-- | 一次进程内存储会话:进程内直连存储动态库
|
||
data StorageSession = StorageSession
|
||
{ dataDir :: FilePath
|
||
}
|
||
|
||
-- | 写一份只属于这次会话的配置:数据目录与日志级别都从文件走
|
||
writeSessionConfig :: FilePath -> IO FilePath
|
||
writeSessionConfig dir = do
|
||
let cfgPath = dir ++ ".toml"
|
||
-- TOML 的普通字符串会吃反斜杠,路径统一用正斜杠
|
||
slashed = map (\c -> if c == '\\' then '/' else c) dir
|
||
writeFile
|
||
cfgPath
|
||
( unlines
|
||
[ "[storage]"
|
||
, "data_dir = \"" ++ slashed ++ "\""
|
||
, "[log]"
|
||
, "level = \"debug\""
|
||
]
|
||
)
|
||
pure cfgPath
|
||
|
||
-- | 打开一份存储,并把它装成引擎当前用的链路
|
||
openSessionAt :: FilePath -> IO (Either String StorageSession)
|
||
openSessionAt dir = do
|
||
cfgPath <- writeSessionConfig dir
|
||
opened <- localStorageLink (Just cfgPath)
|
||
case opened of
|
||
Left e -> pure (Left ("cannot open the chusql_core_storage library: " ++ e))
|
||
Right link -> do
|
||
setStorageLink link
|
||
pure (Right (StorageSession dir))
|
||
|
||
-- | 用同一份数据目录再开一次(模拟重启)
|
||
openSessionIn :: FilePath -> IO ()
|
||
openSessionIn dir = openSessionAt dir >>= either fail (const (pure ()))
|
||
|
||
-- | 每次调用都不同的后缀:CPU 时间加唯一编号
|
||
uniqueStamp :: IO String
|
||
uniqueStamp = do
|
||
cpu <- getCPUTime
|
||
u <- hashUnique <$> newUnique
|
||
pure (show cpu ++ "-" ++ show u)
|
||
|
||
-- | 删掉会话的数据目录与配置文件
|
||
cleanSessionDir :: FilePath -> IO ()
|
||
cleanSessionDir dir = do
|
||
_ <- try (removePathForcibly dir) :: IO (Either IOException ())
|
||
_ <- try (removePathForcibly (dir ++ ".toml")) :: IO (Either IOException ())
|
||
pure ()
|
||
|
||
-- | 一个用例一份干净数据目录;动态库打不开则 pending
|
||
withTestSession :: (StorageSession -> IO ()) -> IO ()
|
||
withTestSession act = do
|
||
tmp <- getTemporaryDirectory
|
||
stamp <- uniqueStamp
|
||
let dir = tmp </> "chusql-hs-IPC" </> stamp
|
||
createDirectoryIfMissing True dir
|
||
opened <- openSessionAt dir
|
||
case opened of
|
||
Left err -> cleanSessionDir dir >> pendingWith err
|
||
Right session -> act session `finally` (closeConnection >> cleanSessionDir dir)
|
||
|
||
-- | 语句不带库名,先建好测试库再切进去
|
||
withServerEnv :: StorageSession -> IO a -> IO a
|
||
withServerEnv _srv act = do
|
||
oldDb <- getDatabaseName
|
||
ensureTestDatabase
|
||
setDatabaseName (Just testDatabase)
|
||
act `finally` setDatabaseName oldDb
|
||
|
||
-- | IPC 用例使用的库名
|
||
testDatabase :: String
|
||
testDatabase = "ipctest"
|
||
|
||
-- | 保证测试库已存在
|
||
ensureTestDatabase :: IO ()
|
||
ensureTestDatabase = do
|
||
listed <- runIPCStorage listDatabases
|
||
case listed of
|
||
Left e -> fail ("cannot list databases through the storage library: " ++ e)
|
||
Right names
|
||
| testDatabase `elem` names -> pure ()
|
||
| otherwise -> do
|
||
created <- runIPCStorage (createDatabase testDatabase)
|
||
case created of
|
||
Right () -> pure ()
|
||
Left e -> fail ("cannot create the test database: " ++ e)
|
||
|
||
-- | 按列名把一行排序
|
||
sortRow :: Row -> Row
|
||
sortRow = sortOn fst
|
||
|
||
-- | 取出索引点查返回的那一行,取不到给 Nothing
|
||
indexRow :: Either String IndexResult -> Maybe Row
|
||
indexRow (Right (IndexRows (r : _))) = Just r
|
||
indexRow _ = Nothing
|
||
|
||
-- | 判断 Either 是 Left
|
||
isLeft :: Either a b -> Bool
|
||
isLeft (Left _) = True
|
||
isLeft (Right _) = False
|
||
|
||
-- | 延时后返回一个值
|
||
delayed :: Int -> a -> IO a
|
||
delayed micros value = threadDelay micros >> pure value
|
||
|
||
-- | 造一个记录调用序列的探针
|
||
newProbe :: Table -> Int -> IO Probe
|
||
newProbe table width = do
|
||
calls <- newMVar []
|
||
pure Probe {probeTable = table, probeCalls = calls, probeWidth = width}
|
||
|
||
-- | 记一条存储调用
|
||
recordCall :: Probe -> String -> IO ()
|
||
recordCall probe name = modifyMVar_ (probeCalls probe) (\calls -> pure (calls ++ [name]))
|
||
|
||
-- | 取回调用序列
|
||
recordedCalls :: Probe -> IO [String]
|
||
recordedCalls = readMVar . probeCalls
|
||
|
||
-- | 按列投影(空清单保留行)
|
||
projectColumns :: [String] -> [Row] -> [Row]
|
||
projectColumns cols = map (\row -> [(c, v) | c <- cols, Just v <- [lookup c row]])
|
||
|
||
-- | 按行数切成连续分片
|
||
rowShards :: Int -> [a] -> [[a]]
|
||
rowShards width rows
|
||
| width <= 1 = [rows]
|
||
| otherwise = go rows
|
||
where
|
||
-- | 每片行数
|
||
chunk = (length rows + width - 1) `div` width
|
||
-- | 反复切出前一片
|
||
go rest
|
||
| null rest = []
|
||
| otherwise = let (part, more) = splitAt chunk rest in part : go more
|
||
|
||
-- | 声明 1000 行的大表
|
||
bigTable :: Table
|
||
bigTable = users {tableName = "big", tableMeta = Just (TableMeta 1000 [("id", 1000, False)] ["id"])}
|
||
|
||
-- | 只装大表的库
|
||
bigDB :: Database
|
||
bigDB = [("big", bigTable)]
|
||
|
||
-- | 记录存储调用、按行分片的假存储
|
||
data Probe = Probe
|
||
{ probeTable :: Table
|
||
, probeCalls :: MVar [String]
|
||
, probeWidth :: Int
|
||
}
|
||
|
||
-- | 探针单子
|
||
newtype ProbeStorage a = ProbeStorage {runProbeStorage :: Probe -> IO a}
|
||
|
||
instance Functor ProbeStorage where
|
||
fmap f (ProbeStorage act) = ProbeStorage (fmap f . act)
|
||
|
||
instance Applicative ProbeStorage where
|
||
pure value = ProbeStorage (\_ -> pure value)
|
||
ProbeStorage fn <*> ProbeStorage act = ProbeStorage (\probe -> fn probe <*> act probe)
|
||
|
||
instance Monad ProbeStorage where
|
||
ProbeStorage act >>= next =
|
||
ProbeStorage (\probe -> act probe >>= \value -> runProbeStorage (next value) probe)
|
||
|
||
instance MonadStorage ProbeStorage where
|
||
scan table = ProbeStorage $ \probe -> do
|
||
recordCall probe ("scan:" ++ table)
|
||
pure (Right (tableRows (probeTable probe)))
|
||
|
||
scanColumns table columns = ProbeStorage $ \probe -> do
|
||
recordCall probe ("scan_columns:" ++ table)
|
||
pure (Right (projectColumns columns (tableRows (probeTable probe))))
|
||
|
||
parallelShards = ProbeStorage (pure . probeWidth)
|
||
|
||
scanShards width table columns = ProbeStorage $ \probe -> do
|
||
recordCall probe ("scan_shards:" ++ show width ++ ":" ++ table)
|
||
let rows = maybe id projectColumns columns (tableRows (probeTable probe))
|
||
pure (Right (concat (rowShards width rows)))
|
||
|
||
insert _ _ = ProbeStorage (\_ -> pure (Left "probe: insert is not supported"))
|
||
replaceAll _ _ = ProbeStorage (\_ -> pure (Left "probe: replaceAll is not supported"))
|
||
createTable _ _ = ProbeStorage (\_ -> pure (Left "probe: createTable is not supported"))
|
||
dropTable _ = ProbeStorage (\_ -> pure (Left "probe: dropTable is not supported"))
|
||
dropColumn _ _ = ProbeStorage (\_ -> pure (Left "probe: dropColumn is not supported"))
|
||
replaceSchema _ _ _ = ProbeStorage (\_ -> pure (Left "probe: replaceSchema is not supported"))
|
||
snapshot = ProbeStorage (\_ -> pure [])
|
||
|
||
-- | 判断 Either 是 Right
|
||
isRight :: Either a b -> Bool
|
||
isRight (Right _) = True
|
||
isRight (Left _) = False
|
||
|
||
-- | 断言优化前后执行结果一致
|
||
sameResultAsUnoptimized :: String -> Expectation
|
||
sameResultAsUnoptimized sql =
|
||
case parseStatement sql >>= prepare testDB of
|
||
Left err -> expectationFailure err
|
||
Right q ->
|
||
case translate q of
|
||
Left err -> expectationFailure err
|
||
Right relOp ->
|
||
evalRelOp testDB (optimize testDB relOp) `shouldBe` evalRelOp testDB relOp
|
||
|
||
-- | 判断谓词是否下推到 Join 左子树
|
||
filterPushedIntoLeft :: RelOp -> Bool
|
||
filterPushedIntoLeft (Project _ (Join _ l _ _)) = pushedIntoLeft l
|
||
filterPushedIntoLeft _ = False
|
||
|
||
-- | 左侧子树里出现了下推下来的谓词(点查 / 范围 / 过滤)
|
||
pushedIntoLeft :: RelOp -> Bool
|
||
pushedIntoLeft (Filter _ _) = True
|
||
pushedIntoLeft (Range _ _ _ _ _) = True
|
||
pushedIntoLeft (Project _ x) = pushedIntoLeft x
|
||
pushedIntoLeft _ = False
|
||
|
||
-- | 解析并优化 SQL,返回计划
|
||
optimizedPlan :: String -> Either String RelOp
|
||
optimizedPlan sql = do
|
||
q <- parseStatement sql
|
||
optimize testDB <$> translate q
|
||
|
||
-- | 在给定库上解析并优化 SQL,返回计划
|
||
optimizedPlanIn :: Database -> String -> Either String RelOp
|
||
optimizedPlanIn db sql = do
|
||
q <- parseStatement sql
|
||
optimize db <$> translate q
|
||
|
||
-- | 计划里出现的表名(先序)
|
||
planTables :: RelOp -> [String]
|
||
planTables op = case op of
|
||
Scan _ t _ -> [t]
|
||
Lookup _ t _ _ -> [t]
|
||
Range _ t _ _ _ -> [t]
|
||
Filter _ x -> planTables x
|
||
Project _ x -> planTables x
|
||
Compute _ x -> planTables x
|
||
Aggregate _ _ x -> planTables x
|
||
Sort _ x -> planTables x
|
||
Limit _ x -> planTables x
|
||
Join _ l r _ -> planTables l ++ planTables r
|
||
Unit -> []
|
||
|
||
-- | 计划里每个 Join 节点是不是 LEFT JOIN(先序)
|
||
leftJoinKinds :: RelOp -> [Bool]
|
||
leftJoinKinds (Join kind l r _) = (kind == LeftJoin) : (leftJoinKinds l ++ leftJoinKinds r)
|
||
leftJoinKinds (Project _ x) = leftJoinKinds x
|
||
leftJoinKinds (Compute _ x) = leftJoinKinds x
|
||
leftJoinKinds (Aggregate _ _ x) = leftJoinKinds x
|
||
leftJoinKinds (Filter _ x) = leftJoinKinds x
|
||
leftJoinKinds (Sort _ x) = leftJoinKinds x
|
||
leftJoinKinds (Limit _ x) = leftJoinKinds x
|
||
leftJoinKinds _ = []
|
||
|
||
-- | 取计划里第一个过滤条件
|
||
firstFilterCond :: RelOp -> Maybe Expr
|
||
firstFilterCond (Filter p _) = Just p
|
||
firstFilterCond (Project _ x) = firstFilterCond x
|
||
firstFilterCond (Compute _ x) = firstFilterCond x
|
||
firstFilterCond (Aggregate _ _ x) = firstFilterCond x
|
||
firstFilterCond Unit = Nothing
|
||
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
|
||
firstFilterCond (Lookup _ _ _ _) = Nothing
|
||
firstFilterCond (Range _ _ _ _ _) = Nothing
|
||
|
||
-- | 判断计划里是否还有过滤节点
|
||
anyFilter :: RelOp -> Bool
|
||
anyFilter (Filter _ _) = True
|
||
anyFilter (Project _ x) = anyFilter x
|
||
anyFilter (Compute _ x) = anyFilter x
|
||
anyFilter (Aggregate _ _ x) = anyFilter x
|
||
anyFilter Unit = False
|
||
anyFilter (Sort _ x) = anyFilter x
|
||
anyFilter (Limit _ x) = anyFilter x
|
||
anyFilter (Join _ l r _) = anyFilter l || anyFilter r
|
||
anyFilter (Scan _ _ _) = False
|
||
anyFilter (Lookup _ _ _ _) = False
|
||
anyFilter (Range _ _ _ _ _) = False
|
||
|
||
-- | 去掉计划里的投影节点
|
||
stripProjects :: RelOp -> RelOp
|
||
stripProjects (Project _ x) = stripProjects x
|
||
stripProjects (Compute items x) = Compute items (stripProjects x)
|
||
stripProjects (Aggregate keys aggs x) = Aggregate keys aggs (stripProjects x)
|
||
stripProjects Unit = Unit
|
||
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 k l r c) = Join k (stripProjects l) (stripProjects r) c
|
||
stripProjects (Scan a t _) = Scan a t Nothing
|
||
stripProjects (Lookup a t c k) = Lookup a t c k
|
||
stripProjects (Range a t c lo hi) = Range a t c lo hi
|
||
|
||
-- | 解析并做投影下推,返回计划
|
||
projectedPlan :: String -> Either String RelOp
|
||
projectedPlan sql = do
|
||
q <- parseStatement sql
|
||
plan <- translate q
|
||
Right (pushProject testDB ["*"] plan)
|
||
|
||
-- | 取节点输出的列名
|
||
sideCols :: RelOp -> Maybe [String]
|
||
sideCols (Project cols _) = Just cols
|
||
sideCols (Scan a _ cols) = fmap (map (qualify a)) cols
|
||
sideCols _ = Nothing
|
||
|
||
-- | 取 Join 左右两侧的输出列名
|
||
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
|
||
|
||
-- | 取 Join 左侧过滤节点数
|
||
leftSideFilters :: RelOp -> Maybe Int
|
||
leftSideFilters (Project _ (Join _ l _ _)) = Just (countFilters l)
|
||
leftSideFilters _ = Nothing
|
||
|
||
-- | 去掉外层投影,返回真正的根节点
|
||
planRoot :: RelOp -> RelOp
|
||
planRoot (Project _ x) = planRoot x
|
||
planRoot x = x
|