9d71edafd4
- Поправени deficiencies #5-#8 (GROUP BY bare columns, aggregate names, sync client с blocking socket, thread-safe Lock в SyncClient) - Добавен BlockingClient (net.Socket + Lock) в clients/nim/src/baradb/client.nim - Обновен src/barabadb/client/client.nim със sync клиент без waitFor - Regression тестове за всички 10 deficiencies - Добавен valueKind в IRExpr за типова информация - evalExprValue връща Value discriminated union - Премахнати parseFloat евристики от irAdd/Sub/Mul/Div/Mod/Pow/Neg - INT+INT→INT, INT+FLOAT→FLOAT, FLOAT/INT→FLOAT - 12 нови теста за type safety Тестове: 311 — 0 failures Build: 0 warnings
3657 lines
137 KiB
Nim
3657 lines
137 KiB
Nim
## BaraDB — Test Suite
|
|
import std/unittest
|
|
import std/tables
|
|
import std/strutils
|
|
import std/os
|
|
import std/asyncdispatch
|
|
import std/monotimes
|
|
import std/base64
|
|
|
|
import barabadb/core/types
|
|
import barabadb/core/mvcc
|
|
import barabadb/core/deadlock
|
|
import barabadb/core/config
|
|
import barabadb/core/server
|
|
import barabadb/core/columnar
|
|
import barabadb/core/raft
|
|
import barabadb/core/sharding
|
|
import barabadb/core/replication
|
|
import barabadb/storage/bloom
|
|
import barabadb/storage/wal
|
|
import barabadb/storage/lsm
|
|
import barabadb/storage/btree
|
|
import barabadb/storage/compaction
|
|
import barabadb/query/lexer as lex
|
|
import barabadb/query/ast
|
|
import barabadb/query/parser
|
|
import barabadb/query/ir as qir
|
|
import barabadb/query/codegen
|
|
import barabadb/query/udf
|
|
import barabadb/vector/simd
|
|
import barabadb/core/crossmodal
|
|
import barabadb/core/gossip
|
|
import barabadb/client/client
|
|
import barabadb/client/fileops
|
|
import barabadb/fts/multilang as mlang
|
|
import barabadb/protocol/zerocopy
|
|
import barabadb/query/adaptive
|
|
import barabadb/query/executor as qexec
|
|
import barabadb/core/disttxn
|
|
import barabadb/vector/engine as vengine
|
|
import barabadb/graph/cypher
|
|
import barabadb/vector/quant as vquant
|
|
import barabadb/storage/recovery
|
|
import barabadb/cli/shell
|
|
import barabadb/protocol/ssl
|
|
import barabadb/graph/engine as gengine
|
|
import barabadb/graph/community as gcomm
|
|
import barabadb/fts/engine as fts
|
|
import barabadb/protocol/wire
|
|
import barabadb/protocol/pool
|
|
import barabadb/protocol/auth
|
|
import barabadb/protocol/scram
|
|
import barabadb/protocol/ratelimit
|
|
import barabadb/schema/schema as schema
|
|
|
|
suite "Core Types":
|
|
test "Value creation":
|
|
let v = Value(kind: vkInt64, int64Val: 42)
|
|
check v.kind == vkInt64
|
|
check v.int64Val == 42
|
|
|
|
test "String value":
|
|
let v = Value(kind: vkString, strVal: "hello")
|
|
check v.strVal == "hello"
|
|
|
|
test "RecordId creation":
|
|
let id = newRecordId()
|
|
check uint64(id) > 0
|
|
|
|
suite "Bloom Filter":
|
|
test "Basic bloom filter operations":
|
|
var bf = newBloomFilter(1000)
|
|
let data1 = cast[seq[byte]]("hello")
|
|
let data2 = cast[seq[byte]]("world")
|
|
|
|
bf.add(data1)
|
|
bf.add(data2)
|
|
|
|
check bf.contains(data1)
|
|
check bf.contains(data2)
|
|
|
|
suite "Write-Ahead Log":
|
|
test "WAL creation":
|
|
var wal = newWriteAheadLog("/tmp/baradb_test_wal")
|
|
check wal.entryCount == 0
|
|
wal.close()
|
|
|
|
suite "LSM-Tree Storage":
|
|
test "Put and Get":
|
|
removeDir("/tmp/baradb_test_lsm")
|
|
var db = newLSMTree("/tmp/baradb_test_lsm")
|
|
let key = "testkey"
|
|
let value = cast[seq[byte]]("testvalue")
|
|
db.put(key, value)
|
|
let (found, val) = db.get(key)
|
|
check found
|
|
check val == value
|
|
db.close()
|
|
|
|
test "Delete":
|
|
removeDir("/tmp/baradb_test_lsm2")
|
|
var db = newLSMTree("/tmp/baradb_test_lsm2")
|
|
let key = "delkey"
|
|
let value = cast[seq[byte]]("delval")
|
|
db.put(key, value)
|
|
db.delete(key)
|
|
let (found, _) = db.get(key)
|
|
check not found
|
|
db.close()
|
|
|
|
test "Contains":
|
|
removeDir("/tmp/baradb_test_lsm3")
|
|
var db = newLSMTree("/tmp/baradb_test_lsm3")
|
|
let key = "exists"
|
|
check not db.contains(key)
|
|
db.put(key, cast[seq[byte]]("val"))
|
|
check db.contains(key)
|
|
db.close()
|
|
|
|
suite "BaraQL Lexer":
|
|
test "Tokenize simple SELECT":
|
|
let tokens = lex.tokenize("SELECT name FROM users WHERE age > 18")
|
|
check tokens.len > 0
|
|
check tokens[0].kind == tkSelect
|
|
check tokens[1].kind == tkIdent
|
|
check tokens[1].value == "name"
|
|
check tokens[2].kind == tkFrom
|
|
check tokens[3].kind == tkIdent
|
|
check tokens[3].value == "users"
|
|
|
|
test "Tokenize string literals":
|
|
let tokens = lex.tokenize("'hello world'")
|
|
check tokens[0].kind == tkStringLit
|
|
check tokens[0].value == "hello world"
|
|
|
|
test "Tokenize operators":
|
|
let tokens = lex.tokenize("a + b * c")
|
|
check tokens[0].kind == tkIdent
|
|
check tokens[1].kind == tkPlus
|
|
check tokens[2].kind == tkIdent
|
|
check tokens[3].kind == tkStar
|
|
|
|
suite "BaraQL Parser":
|
|
test "Parse simple SELECT":
|
|
let ast = parse("SELECT name FROM users WHERE age > 18")
|
|
check ast.kind == nkStatementList
|
|
check ast.stmts.len == 1
|
|
check ast.stmts[0].kind == nkSelect
|
|
|
|
test "Parse SELECT with LIMIT":
|
|
let ast = parse("SELECT * FROM items LIMIT 10")
|
|
check ast.stmts[0].selLimit != nil
|
|
|
|
suite "Vector Engine":
|
|
test "Distance metrics":
|
|
let a = @[1.0'f32, 0.0'f32, 0.0'f32]
|
|
let b = @[0.0'f32, 1.0'f32, 0.0'f32]
|
|
let c = @[1.0'f32, 0.0'f32, 0.0'f32]
|
|
|
|
check vengine.cosineDistance(a, b) > 0.9
|
|
check vengine.cosineDistance(a, c) < 0.1
|
|
check vengine.euclideanDistance(a, b) > 1.0
|
|
check vengine.euclideanDistance(a, c) < 0.1
|
|
|
|
test "HNSW index insert and search":
|
|
var idx = vengine.newHNSWIndex(3)
|
|
vengine.insert(idx, 1, @[1.0'f32, 0.0'f32, 0.0'f32])
|
|
vengine.insert(idx, 2, @[0.0'f32, 1.0'f32, 0.0'f32])
|
|
vengine.insert(idx, 3, @[0.0'f32, 0.0'f32, 1.0'f32])
|
|
|
|
let results = vengine.search(idx, @[1.0'f32, 0.1'f32, 0.0'f32], 2)
|
|
check results.len == 2
|
|
|
|
suite "Graph Engine":
|
|
test "Add nodes and edges":
|
|
var g = gengine.newGraph()
|
|
let n1 = gengine.addNode(g, "Person", {"name": "Alice"}.toTable)
|
|
let n2 = gengine.addNode(g, "Person", {"name": "Bob"}.toTable)
|
|
let e1 = gengine.addEdge(g, n1, n2, "knows")
|
|
|
|
check gengine.nodeCount(g) == 2
|
|
check gengine.edgeCount(g) == 1
|
|
|
|
test "BFS traversal":
|
|
var g = gengine.newGraph()
|
|
let n1 = gengine.addNode(g, "A")
|
|
let n2 = gengine.addNode(g, "B")
|
|
let n3 = gengine.addNode(g, "C")
|
|
let n4 = gengine.addNode(g, "D")
|
|
discard gengine.addEdge(g, n1, n2)
|
|
discard gengine.addEdge(g, n1, n3)
|
|
discard gengine.addEdge(g, n2, n4)
|
|
|
|
let traversal = gengine.bfs(g, n1)
|
|
check traversal.len == 4
|
|
check traversal[0] == n1
|
|
|
|
test "DFS traversal":
|
|
var g = gengine.newGraph()
|
|
let n1 = gengine.addNode(g, "A")
|
|
let n2 = gengine.addNode(g, "B")
|
|
let n3 = gengine.addNode(g, "C")
|
|
discard gengine.addEdge(g, n1, n2)
|
|
discard gengine.addEdge(g, n1, n3)
|
|
|
|
let traversal = gengine.dfs(g, n1)
|
|
check traversal.len == 3
|
|
|
|
test "Shortest path":
|
|
var g = gengine.newGraph()
|
|
let n1 = gengine.addNode(g, "A")
|
|
let n2 = gengine.addNode(g, "B")
|
|
let n3 = gengine.addNode(g, "C")
|
|
discard gengine.addEdge(g, n1, n2)
|
|
discard gengine.addEdge(g, n2, n3)
|
|
|
|
let path = gengine.shortestPath(g, n1, n3)
|
|
check path.len == 3
|
|
|
|
test "PageRank":
|
|
var g = gengine.newGraph()
|
|
let n1 = gengine.addNode(g, "A")
|
|
let n2 = gengine.addNode(g, "B")
|
|
let n3 = gengine.addNode(g, "C")
|
|
discard gengine.addEdge(g, n1, n2)
|
|
discard gengine.addEdge(g, n2, n3)
|
|
discard gengine.addEdge(g, n3, n1)
|
|
|
|
let ranks = gengine.pageRank(g)
|
|
check ranks.len == 3
|
|
for nodeId, rank in ranks:
|
|
check rank > 0.0
|
|
|
|
test "Dijkstra":
|
|
var g = gengine.newGraph()
|
|
let n1 = gengine.addNode(g, "A")
|
|
let n2 = gengine.addNode(g, "B")
|
|
let n3 = gengine.addNode(g, "C")
|
|
discard gengine.addEdge(g, n1, n2, weight = 1.0)
|
|
discard gengine.addEdge(g, n2, n3, weight = 2.0)
|
|
discard gengine.addEdge(g, n1, n3, weight = 10.0)
|
|
|
|
let dists = gengine.dijkstra(g, n1)
|
|
check dists[n1] == 0.0
|
|
check dists[n2] == 1.0
|
|
check dists[n3] == 3.0
|
|
|
|
test "Save and load graph":
|
|
var g = gengine.newGraph()
|
|
let n1 = gengine.addNode(g, "Person", {"name": "Alice"}.toTable)
|
|
let n2 = gengine.addNode(g, "Person", {"name": "Bob"}.toTable)
|
|
let n3 = gengine.addNode(g, "City", {"name": "Sofia"}.toTable)
|
|
discard gengine.addEdge(g, n1, n2, "knows", {"since": "2020"}.toTable, 1.5)
|
|
discard gengine.addEdge(g, n2, n3, "lives_in", weight = 2.0)
|
|
|
|
let path = "/tmp/baradb_test_graph.bin"
|
|
removeFile(path)
|
|
gengine.saveToFile(g, path)
|
|
|
|
let g2 = gengine.loadFromFile(path)
|
|
check gengine.nodeCount(g2) == 3
|
|
check gengine.edgeCount(g2) == 2
|
|
check gengine.neighbors(g2, n1).len == 1
|
|
check gengine.neighbors(g2, n1)[0] == n2
|
|
check gengine.neighbors(g2, n2)[0] == n3
|
|
|
|
let loadedNode = gengine.getNode(g2, n1)
|
|
check loadedNode.label == "Person"
|
|
check loadedNode.properties["name"] == "Alice"
|
|
|
|
let sp = gengine.shortestPath(g2, n1, n3)
|
|
check sp.len == 3
|
|
|
|
removeFile(path)
|
|
|
|
suite "Full-Text Search":
|
|
test "Tokenization":
|
|
let tokens = fts.tokenize("The quick brown fox jumps over the lazy dog")
|
|
check tokens.len > 0
|
|
check "the" notin tokens
|
|
|
|
test "Inverted index operations":
|
|
var idx = fts.newInvertedIndex()
|
|
fts.addDocument(idx, 1, "The quick brown fox")
|
|
fts.addDocument(idx, 2, "The lazy brown dog")
|
|
fts.addDocument(idx, 3, "The quick red car")
|
|
|
|
check fts.documentCount(idx) == 3
|
|
check fts.termCount(idx) > 0
|
|
|
|
test "Search results":
|
|
var idx = fts.newInvertedIndex()
|
|
fts.addDocument(idx, 1, "Nim programming language is fast")
|
|
fts.addDocument(idx, 2, "Python is popular for data science")
|
|
fts.addDocument(idx, 3, "Rust is a systems programming language")
|
|
|
|
let results = fts.search(idx, "programming language")
|
|
check results.len > 0
|
|
check results[0].score > 0
|
|
|
|
test "Document removal":
|
|
var idx = fts.newInvertedIndex()
|
|
fts.addDocument(idx, 1, "test document")
|
|
fts.addDocument(idx, 2, "another document")
|
|
check fts.documentCount(idx) == 2
|
|
|
|
fts.removeDocument(idx, 1)
|
|
check fts.documentCount(idx) == 1
|
|
|
|
suite "MVCC Transactions":
|
|
test "Begin and commit transaction":
|
|
var tm = newTxnManager()
|
|
let txn = tm.beginTxn()
|
|
check txn.state == tsActive
|
|
check tm.write(txn, "key1", cast[seq[byte]]("value1"))
|
|
check tm.commit(txn)
|
|
check txn.state == tsCommitted
|
|
|
|
test "Read own writes":
|
|
var tm = newTxnManager()
|
|
let txn = tm.beginTxn()
|
|
discard tm.write(txn, "key1", cast[seq[byte]]("value1"))
|
|
let (found, val) = tm.read(txn, "key1")
|
|
check found
|
|
check val == cast[seq[byte]]("value1")
|
|
discard tm.commit(txn)
|
|
|
|
test "Abort transaction":
|
|
var tm = newTxnManager()
|
|
let txn = tm.beginTxn()
|
|
discard tm.write(txn, "key1", cast[seq[byte]]("value1"))
|
|
discard tm.abortTxn(txn)
|
|
check txn.state == tsAborted
|
|
|
|
test "Snapshot isolation — no dirty reads":
|
|
var tm = newTxnManager()
|
|
let txn1 = tm.beginTxn()
|
|
discard tm.write(txn1, "key1", cast[seq[byte]]("value1"))
|
|
|
|
let txn2 = tm.beginTxn()
|
|
let (found, _) = tm.read(txn2, "key1")
|
|
check not found # txn2 can't see txn1's uncommitted write
|
|
|
|
discard tm.commit(txn1)
|
|
# txn2 still can't see it (snapshot taken before commit)
|
|
let (found2, _) = tm.read(txn2, "key1")
|
|
check not found2
|
|
discard tm.abortTxn(txn2)
|
|
|
|
test "Committed writes visible to new transactions":
|
|
var tm = newTxnManager()
|
|
let txn1 = tm.beginTxn()
|
|
discard tm.write(txn1, "key1", cast[seq[byte]]("value1"))
|
|
discard tm.commit(txn1)
|
|
|
|
let txn2 = tm.beginTxn()
|
|
let (found, val) = tm.read(txn2, "key1")
|
|
check found
|
|
check val == cast[seq[byte]]("value1")
|
|
discard tm.commit(txn2)
|
|
|
|
test "Savepoint and rollback":
|
|
var tm = newTxnManager()
|
|
let txn = tm.beginTxn()
|
|
discard tm.write(txn, "key1", cast[seq[byte]]("value1"))
|
|
tm.savepoint(txn)
|
|
discard tm.write(txn, "key2", cast[seq[byte]]("value2"))
|
|
check tm.rollbackToSavepoint(txn)
|
|
let (found1, _) = tm.read(txn, "key1")
|
|
check found1
|
|
let (found2, _) = tm.read(txn, "key2")
|
|
check not found2
|
|
discard tm.commit(txn)
|
|
|
|
test "Delete via xmax":
|
|
var tm = newTxnManager()
|
|
let txn1 = tm.beginTxn()
|
|
discard tm.write(txn1, "key1", cast[seq[byte]]("value1"))
|
|
discard tm.commit(txn1)
|
|
|
|
let txn2 = tm.beginTxn()
|
|
discard tm.delete(txn2, "key1")
|
|
discard tm.commit(txn2)
|
|
|
|
let txn3 = tm.beginTxn()
|
|
let (found, _) = tm.read(txn3, "key1")
|
|
check not found
|
|
discard tm.commit(txn3)
|
|
|
|
suite "Deadlock Detection":
|
|
test "No deadlock without cycles":
|
|
var dd = newDeadlockDetector()
|
|
dd.addWait(1, 2)
|
|
dd.addWait(2, 3)
|
|
check not dd.hasDeadlock()
|
|
|
|
test "Detect simple deadlock":
|
|
var dd = newDeadlockDetector()
|
|
dd.addWait(1, 2)
|
|
dd.addWait(2, 1)
|
|
check dd.hasDeadlock()
|
|
|
|
test "Find deadlock victim":
|
|
var dd = newDeadlockDetector()
|
|
dd.addWait(1, 2)
|
|
dd.addWait(2, 3)
|
|
dd.addWait(3, 1)
|
|
let victim = dd.findDeadlockVictim()
|
|
check victim == 3 # youngest txn
|
|
|
|
test "Remove transaction clears edges":
|
|
var dd = newDeadlockDetector()
|
|
dd.addWait(1, 2)
|
|
dd.addWait(2, 1)
|
|
dd.removeTxn(2)
|
|
check not dd.hasDeadlock()
|
|
|
|
suite "MVCC Deadlock Detection":
|
|
test "TxnManager detects and breaks deadlock":
|
|
var tm = newTxnManager()
|
|
var t1 = tm.beginTxn()
|
|
var t2 = tm.beginTxn()
|
|
# t1 writes key "a"
|
|
check tm.write(t1, "a", @[1'u8])
|
|
# t2 writes key "b"
|
|
check tm.write(t2, "b", @[2'u8])
|
|
# t2 tries to write "a" — conflicts with t1 (active), adds wait edge t2->t1
|
|
check not tm.write(t2, "a", @[3'u8])
|
|
# t1 tries to write "b" — conflicts with t2 (active), adds wait edge t1->t2
|
|
# This creates a cycle: t1->t2->t1
|
|
check not tm.write(t1, "b", @[4'u8])
|
|
# One of the transactions should have been aborted as victim
|
|
let t1Active = t1.state == tsActive
|
|
let t2Active = t2.state == tsActive
|
|
# At least one victim must be aborted
|
|
check (not t1Active) or (not t2Active)
|
|
# The survivor should be able to commit
|
|
if t1Active:
|
|
check tm.commit(t1)
|
|
if t2Active:
|
|
check tm.commit(t2)
|
|
|
|
test "No false deadlock on sequential writes":
|
|
var tm = newTxnManager()
|
|
var t1 = tm.beginTxn()
|
|
# t1 writes key "a"
|
|
check tm.write(t1, "a", @[1'u8])
|
|
# t1 commits
|
|
check tm.commit(t1)
|
|
# t2 begins after t1 committed
|
|
var t2 = tm.beginTxn()
|
|
# t2 writes same key — no active conflict
|
|
check tm.write(t2, "a", @[2'u8])
|
|
check tm.commit(t2)
|
|
|
|
suite "Wire Protocol":
|
|
test "Value serialization roundtrip":
|
|
var buf: seq[byte] = @[]
|
|
let val = WireValue(kind: fkString, strVal: "hello world")
|
|
buf.serializeValue(val)
|
|
var pos = 0
|
|
let decoded = buf.deserializeValue(pos)
|
|
check decoded.kind == fkString
|
|
check decoded.strVal == "hello world"
|
|
|
|
test "Int64 serialization":
|
|
var buf: seq[byte] = @[]
|
|
let val = WireValue(kind: fkInt64, int64Val: 42)
|
|
buf.serializeValue(val)
|
|
var pos = 0
|
|
let decoded = buf.deserializeValue(pos)
|
|
check decoded.kind == fkInt64
|
|
check decoded.int64Val == 42
|
|
|
|
test "Array serialization":
|
|
var buf: seq[byte] = @[]
|
|
let val = WireValue(kind: fkArray, arrayVal: @[
|
|
WireValue(kind: fkInt32, int32Val: 1),
|
|
WireValue(kind: fkInt32, int32Val: 2),
|
|
WireValue(kind: fkInt32, int32Val: 3),
|
|
])
|
|
buf.serializeValue(val)
|
|
var pos = 0
|
|
let decoded = buf.deserializeValue(pos)
|
|
check decoded.kind == fkArray
|
|
check decoded.arrayVal.len == 3
|
|
check decoded.arrayVal[1].int32Val == 2
|
|
|
|
test "Vector serialization":
|
|
var buf: seq[byte] = @[]
|
|
let val = WireValue(kind: fkVector, vecVal: @[1.0'f32, 2.0'f32, 3.0'f32])
|
|
buf.serializeValue(val)
|
|
var pos = 0
|
|
let decoded = buf.deserializeValue(pos)
|
|
check decoded.kind == fkVector
|
|
check decoded.vecVal.len == 3
|
|
|
|
test "Query message creation":
|
|
let msg = makeQueryMessage(1, "SELECT * FROM users")
|
|
check msg.len > 0
|
|
check msg[3] == byte(mkQuery) # big-endian uint32, last byte
|
|
|
|
suite "Schema System":
|
|
test "Create type with properties":
|
|
var s = newSchema()
|
|
let person = newType("Person")
|
|
person.addProperty("name", "str", required = true)
|
|
person.addProperty("age", "int32")
|
|
s.addType("default", person)
|
|
check s.getType("Person") != nil
|
|
check s.getType("Person").properties.len == 2
|
|
|
|
test "Create type with links":
|
|
var s = newSchema()
|
|
let person = newType("Person")
|
|
person.addProperty("name", "str", required = true)
|
|
s.addType("default", person)
|
|
|
|
let movie = newType("Movie")
|
|
movie.addProperty("title", "str", required = true)
|
|
movie.addLink("actors", "Person", multi = true)
|
|
s.addType("default", movie)
|
|
|
|
check movie.links.len == 1
|
|
check movie.links["actors"].target == "Person"
|
|
|
|
test "Schema diff":
|
|
let s1 = newSchema()
|
|
let t1 = newType("Person")
|
|
t1.addProperty("name", "str")
|
|
s1.addType("default", t1)
|
|
|
|
let s2 = newSchema()
|
|
let t2 = newType("Person")
|
|
t2.addProperty("name", "str")
|
|
t2.addProperty("age", "int32")
|
|
s2.addType("default", t2)
|
|
let movieT = newType("Movie")
|
|
movieT.addProperty("title", "str")
|
|
s2.addType("default", movieT)
|
|
|
|
let d = diff(s1, s2)
|
|
check d.addedTypes.len == 1
|
|
check d.addedTypes[0] == "Movie"
|
|
check d.modifiedTypes.len == 1
|
|
check d.modifiedTypes[0].addedProperties.len == 1
|
|
|
|
test "Type validation":
|
|
let t = newType("Person")
|
|
t.addProperty("name", "str", required = true)
|
|
t.addLink("friend", "") # empty target
|
|
let errors = t.validateType()
|
|
check errors.len == 1 # empty link target
|
|
|
|
test "Migration creation":
|
|
var s = newSchema()
|
|
let m1 = s.createMigration("initial", "CREATE TYPE Person { name: str }")
|
|
check m1.id == 1
|
|
let m2 = s.createMigration("add age", "ALTER TYPE Person { ADD age: int32 }")
|
|
check m2.id == 2
|
|
check m2.parentId == 1
|
|
|
|
test "Type to string":
|
|
let t = newType("Person")
|
|
t.addProperty("name", "str", required = true)
|
|
t.addProperty("age", "int32")
|
|
t.addLink("friend", "Person")
|
|
let s = $t
|
|
check s.find("Person") >= 0
|
|
check s.find("name") >= 0
|
|
check s.find("str") >= 0
|
|
|
|
suite "B-Tree Index":
|
|
test "Insert and get":
|
|
var btree = newBTreeIndex[string, string]()
|
|
btree.insert("key1", "value1")
|
|
btree.insert("key2", "value2")
|
|
check btree.get("key1") == @["value1"]
|
|
check btree.get("key2") == @["value2"]
|
|
check not btree.contains("nonexistent")
|
|
|
|
test "Scan range":
|
|
var btree = newBTreeIndex[string, string]()
|
|
for i in 0..9:
|
|
btree.insert("key" & $i, "val" & $i)
|
|
let results = btree.scan("key2", "key5")
|
|
check results.len == 4
|
|
|
|
test "Duplicate keys":
|
|
var btree = newBTreeIndex[string, string]()
|
|
btree.insert("a", "val1")
|
|
btree.insert("a", "val2")
|
|
let vals = btree.get("a")
|
|
check vals.len == 2
|
|
|
|
suite "Columnar Engine":
|
|
test "Column batch operations":
|
|
var batch = newColumnBatch()
|
|
var intCol = batch.addInt64Col("age")
|
|
var strCol = batch.addStringCol("name")
|
|
intCol.appendInt64(25)
|
|
intCol.appendInt64(30)
|
|
intCol.appendInt64(35)
|
|
strCol.appendString("Alice")
|
|
strCol.appendString("Bob")
|
|
strCol.appendString("Charlie")
|
|
check batch.rowCount() == 3
|
|
|
|
test "Aggregate operations":
|
|
var batch = newColumnBatch()
|
|
var col = batch.addInt64Col("age")
|
|
col.appendInt64(10)
|
|
col.appendInt64(20)
|
|
col.appendInt64(30)
|
|
check col.sumInt64() == 60
|
|
check col.avgInt64() - 20.0 < 0.001
|
|
check col.minInt64() == 10
|
|
check col.maxInt64() == 30
|
|
check col.count() == 3
|
|
|
|
test "RLE encoding":
|
|
let data = @[1'i64, 1, 1, 2, 2, 3, 3, 3, 3]
|
|
let encoded = rleEncode(data)
|
|
let decoded = rleDecode(encoded)
|
|
check decoded == data
|
|
|
|
test "Dictionary encoding":
|
|
let data = @["apple", "banana", "apple", "cherry", "banana"]
|
|
let encoded = dictEncode(data)
|
|
let decoded = dictDecode(encoded)
|
|
check decoded == data
|
|
check encoded.dict.len == 3
|
|
|
|
test "GroupBy":
|
|
var batch = newColumnBatch()
|
|
var deptCol = batch.addStringCol("department")
|
|
var salaryCol = batch.addInt64Col("salary")
|
|
deptCol.appendString("Engineering")
|
|
deptCol.appendString("Sales")
|
|
deptCol.appendString("Engineering")
|
|
salaryCol.appendInt64(100)
|
|
salaryCol.appendInt64(80)
|
|
salaryCol.appendInt64(120)
|
|
|
|
let groups = groupBy(batch, @["department"])
|
|
check groups.groups.len == 2 # unique departments
|
|
|
|
suite "Type Checker & IR":
|
|
test "Literal type inference":
|
|
var tc = newTypeChecker()
|
|
let lit = IRExpr(kind: irekLiteral, literal: IRLiteral(kind: vkInt64, int64Val: 42))
|
|
let t = tc.inferExpr(lit, initTable[string, IRType]())
|
|
check t.name == "int64"
|
|
|
|
test "Binary operation type inference":
|
|
var tc = newTypeChecker()
|
|
let left = IRExpr(kind: irekLiteral, literal: IRLiteral(kind: vkInt64, int64Val: 1))
|
|
let right = IRExpr(kind: irekLiteral, literal: IRLiteral(kind: vkInt64, int64Val: 2))
|
|
let bin = IRExpr(kind: irekBinary, binOp: irEq, binLeft: left, binRight: right)
|
|
let t = tc.inferExpr(bin, initTable[string, IRType]())
|
|
check t.name == "bool"
|
|
|
|
test "Aggregate type inference":
|
|
var tc = newTypeChecker()
|
|
let agg = IRExpr(kind: irekAggregate, aggOp: irCount)
|
|
let t = tc.inferExpr(agg, initTable[string, IRType]())
|
|
check t.name == "int64"
|
|
|
|
suite "Connection Pool":
|
|
test "Create pool and acquire connection":
|
|
var pool = newConnectionPool("127.0.0.1", 9472)
|
|
let conn = pool.acquire()
|
|
check conn != nil
|
|
check conn.host == "127.0.0.1"
|
|
check conn.port == 9472
|
|
pool.release(conn)
|
|
|
|
test "Pool stats":
|
|
var cfg = defaultPoolConfig()
|
|
cfg.minConnections = 1
|
|
cfg.maxConnections = 10
|
|
var pool = newConnectionPool("127.0.0.1", 9472, "default", cfg)
|
|
let conn1 = pool.acquire()
|
|
let (total, idle, inUse) = pool.stats()
|
|
check inUse == 1
|
|
pool.release(conn1)
|
|
let (t2, i2, u2) = pool.stats()
|
|
check u2 == 0
|
|
|
|
suite "Authentication":
|
|
test "Anonymous auth":
|
|
var am = newAuthManager()
|
|
let result = am.validateCredentials(AuthCredentials(authMethod: amNone))
|
|
check result.authenticated
|
|
check result.username == "anonymous"
|
|
|
|
test "Token auth":
|
|
var am = newAuthManager("mysecretkey")
|
|
let token = am.createToken(JWTClaims(sub: "user1", role: "admin"))
|
|
let result = am.validateCredentials(AuthCredentials(
|
|
authMethod: amToken, payload: token))
|
|
check result.authenticated
|
|
|
|
test "Invalid token":
|
|
var am = newAuthManager("mysecretkey")
|
|
let result = am.validateCredentials(AuthCredentials(
|
|
authMethod: amToken, payload: "invalid_token"))
|
|
check not result.authenticated
|
|
|
|
test "SCRAM-SHA-256 full handshake":
|
|
var am = newAuthManager()
|
|
am.registerScramUser("alice", "wonderland")
|
|
|
|
# Step 1: ClientFirstMessage
|
|
let clientNonce = generateNonce()
|
|
let clientFirst = "n,,n=alice,r=" & clientNonce
|
|
|
|
# Step 2: ServerFirstMessage
|
|
let serverFirst = am.startScram(clientFirst)
|
|
check serverFirst.startsWith("r=")
|
|
check serverFirst.contains("s=")
|
|
check serverFirst.contains("i=4096")
|
|
|
|
# Parse serverFirst
|
|
var combinedNonce = ""
|
|
var saltB64 = ""
|
|
var iterCount = 0
|
|
for part in serverFirst.split(","):
|
|
if part.startsWith("r="): combinedNonce = part[2..^1]
|
|
elif part.startsWith("s="): saltB64 = part[2..^1]
|
|
elif part.startsWith("i="): iterCount = parseInt(part[2..^1])
|
|
check combinedNonce.len > 0
|
|
check saltB64.len > 0
|
|
check iterCount == 4096
|
|
|
|
# Step 3: Compute client proof
|
|
let salt = decode(saltB64)
|
|
let saltedPassword = pbkdf2HmacSha256("wonderland", salt, iterCount)
|
|
let clientKey = hmacSha256(saltedPassword, "Client Key")
|
|
let storedKey = sha256(clientKey)
|
|
let serverKey = hmacSha256(saltedPassword, "Server Key")
|
|
|
|
let clientFirstBare = "n=alice,r=" & clientNonce
|
|
let clientFinalWithoutProof = "c=biws,r=" & combinedNonce
|
|
let authMessage = clientFirstBare & "," & serverFirst & "," & clientFinalWithoutProof
|
|
|
|
let clientSignature = hmacSha256(storedKey, authMessage)
|
|
var clientProof = newSeq[byte](32)
|
|
for i in 0..<32:
|
|
clientProof[i] = clientKey[i] xor clientSignature[i]
|
|
var proofStr = newString(32)
|
|
copyMem(addr proofStr[0], addr clientProof[0], 32)
|
|
let proofB64 = encode(proofStr)
|
|
# Strip padding
|
|
var proofB64Clean = proofB64
|
|
while proofB64Clean.endsWith("="):
|
|
proofB64Clean.setLen(proofB64Clean.len - 1)
|
|
proofB64Clean = proofB64Clean.replace("+", "-").replace("/", "_")
|
|
|
|
let clientFinal = "c=biws,r=" & combinedNonce & ",p=" & proofB64Clean
|
|
|
|
# Step 4: Server verifies
|
|
let (ok, serverFinal) = am.finishScram(clientFinal)
|
|
check ok
|
|
check serverFinal.startsWith("v=")
|
|
|
|
# Verify server signature ourselves
|
|
let expectedServerSig = hmacSha256(serverKey, authMessage)
|
|
var expectedStr = newString(32)
|
|
copyMem(addr expectedStr[0], unsafeAddr expectedServerSig[0], 32)
|
|
let expectedB64 = encode(expectedStr)
|
|
check serverFinal == "v=" & expectedB64
|
|
|
|
test "SCRAM-SHA-256 invalid proof rejected":
|
|
var am = newAuthManager()
|
|
am.registerScramUser("bob", "builder")
|
|
|
|
let clientNonce = generateNonce()
|
|
let clientFirst = "n,,n=bob,r=" & clientNonce
|
|
let serverFirst = am.startScram(clientFirst)
|
|
|
|
var combinedNonce = ""
|
|
for part in serverFirst.split(","):
|
|
if part.startsWith("r="): combinedNonce = part[2..^1]
|
|
|
|
let clientFinal = "c=biws,r=" & combinedNonce & ",p=invalidproof"
|
|
let (ok, serverFinal) = am.finishScram(clientFinal)
|
|
check not ok
|
|
check serverFinal == "e=invalid-proof"
|
|
|
|
suite "Vector Quantization":
|
|
test "Scalar quantization 8-bit":
|
|
var sq = newScalarQuantizer(4, bits = 8)
|
|
let vectors = @[@[1.0'f32, 2.0'f32, 3.0'f32, 4.0'f32],
|
|
@[5.0'f32, 6.0'f32, 7.0'f32, 8.0'f32]]
|
|
sq.train(vectors)
|
|
let qv = sq.encode(@[3.0'f32, 4.0'f32, 5.0'f32, 6.0'f32])
|
|
check qv.kind == qkScalar8
|
|
check qv.int8Data.len == 4
|
|
|
|
test "Scalar quantization 4-bit":
|
|
var sq = newScalarQuantizer(4, bits = 4)
|
|
let vectors = @[@[1.0'f32, 2.0'f32, 3.0'f32, 4.0'f32]]
|
|
sq.train(vectors)
|
|
let qv = sq.encode(@[3.0'f32, 4.0'f32, 5.0'f32, 6.0'f32])
|
|
check qv.kind == qkScalar4
|
|
check qv.int4Data.len == 2
|
|
|
|
test "Product quantization":
|
|
var pq = newProductQuantizer(8, nSubspaces = 4, nClusters = 16)
|
|
var vectors: seq[seq[float32]] = @[]
|
|
for i in 0..<50:
|
|
var v: seq[float32] = @[]
|
|
for j in 0..<8:
|
|
v.add(float32(i * 8 + j) * 0.1)
|
|
vectors.add(v)
|
|
pq.train(vectors, nIterations = 5)
|
|
let qv = pq.encode(vectors[0])
|
|
check qv.kind == qkProduct
|
|
check qv.pqCodes.len == 4
|
|
|
|
test "Binary quantization":
|
|
let v = @[1.0'f32, -1.0'f32, 0.5'f32, -0.5'f32]
|
|
let qv = binaryQuantize(v)
|
|
check qv.kind == qkBinary
|
|
check qv.binData.len == 1
|
|
|
|
suite "Louvain Community Detection":
|
|
test "Detect communities in simple graph":
|
|
var g = gengine.newGraph()
|
|
# Create two communities
|
|
let n1 = gengine.addNode(g, "A")
|
|
let n2 = gengine.addNode(g, "B")
|
|
let n3 = gengine.addNode(g, "C")
|
|
let n4 = gengine.addNode(g, "D")
|
|
# Community 1: fully connected
|
|
discard gengine.addEdge(g, n1, n2)
|
|
discard gengine.addEdge(g, n2, n3)
|
|
discard gengine.addEdge(g, n1, n3)
|
|
# Community 2
|
|
discard gengine.addEdge(g, n3, n4) # single connection
|
|
|
|
let result = louvain(g)
|
|
check result.communities.len > 0
|
|
check result.numCommunities >= 1
|
|
|
|
test "Pattern matching":
|
|
var g = gengine.newGraph()
|
|
let a = gengine.addNode(g, "Person", {"name": "Alice"}.toTable)
|
|
let b = gengine.addNode(g, "Person", {"name": "Bob"}.toTable)
|
|
let c = gengine.addNode(g, "Person", {"name": "Charlie"}.toTable)
|
|
discard gengine.addEdge(g, a, b, "knows")
|
|
discard gengine.addEdge(g, b, c, "knows")
|
|
discard gengine.addEdge(g, a, c, "knows")
|
|
|
|
var pattern = newGraphPattern()
|
|
pattern.addNode(0, "Person", {"name": "Alice"}.toTable)
|
|
pattern.addNode(1, "Person")
|
|
pattern.addEdge(0, 1, "knows")
|
|
|
|
let matches = matchPattern(g, pattern)
|
|
check matches.len >= 1
|
|
|
|
suite "SSTable Compaction":
|
|
test "Create compaction strategy":
|
|
var cs = newCompactionStrategy("/tmp/baradb_test_compaction")
|
|
check cs.levelCount == 0
|
|
check cs.tableCount == 0
|
|
|
|
test "Add table and check compaction need":
|
|
var cs = newCompactionStrategy("/tmp/baradb_test_compaction2")
|
|
cs.addTable(SSTableMeta(path: "test.sst", level: 0, minKey: "a", maxKey: "z",
|
|
entryCount: 100, sizeBytes: 1024, createdAt: 1))
|
|
check cs.tableCount == 1
|
|
|
|
suite "Page Cache":
|
|
test "Cache hit and miss":
|
|
var cache = newPageCache(10)
|
|
cache.put("key1", cast[seq[byte]]("data1"))
|
|
let (found, data) = cache.get("key1")
|
|
check found
|
|
check cache.hits == 1
|
|
|
|
let (found2, _) = cache.get("missing")
|
|
check not found2
|
|
check cache.misses == 1
|
|
|
|
test "LRU eviction":
|
|
var cache = newPageCache(2)
|
|
cache.put("a", cast[seq[byte]]("1"))
|
|
cache.put("b", cast[seq[byte]]("2"))
|
|
cache.put("c", cast[seq[byte]]("3")) # evicts "a"
|
|
check cache.len == 2
|
|
let (found, _) = cache.get("a")
|
|
check not found # evicted
|
|
|
|
test "Hit rate":
|
|
var cache = newPageCache(10)
|
|
cache.put("k", cast[seq[byte]]("v"))
|
|
discard cache.get("k")
|
|
discard cache.get("k")
|
|
discard cache.get("miss")
|
|
check cache.hitRate - 0.666 < 0.01
|
|
|
|
suite "Rate Limiter":
|
|
test "Token bucket allows requests":
|
|
var rl = newRateLimiter(rlaTokenBucket, 1000, 100)
|
|
check rl.allowRequest("client1")
|
|
check rl.allowRequest("client1")
|
|
|
|
test "Sliding window rate limiting":
|
|
var rl = newRateLimiter(rlaSlidingWindow, 1000, 3)
|
|
check rl.allowRequest("client1")
|
|
check rl.allowRequest("client1")
|
|
check rl.allowRequest("client1")
|
|
check not rl.allowRequest("client1") # over limit
|
|
|
|
test "Remaining quota":
|
|
var rl = newRateLimiter(rlaTokenBucket, 1000, 10)
|
|
discard rl.allowRequest("c1")
|
|
let remaining = rl.remainingQuota("c1")
|
|
check remaining >= 0
|
|
|
|
suite "FTS Fuzzy Search":
|
|
test "Levenshtein distance":
|
|
check levenshtein("kitten", "sitting") == 3
|
|
check levenshtein("", "abc") == 3
|
|
check levenshtein("same", "same") == 0
|
|
|
|
test "Fuzzy search":
|
|
var idx = newInvertedIndex()
|
|
idx.addDocument(1, "Nim programming language")
|
|
idx.addDocument(2, "Python is popular")
|
|
let results = idx.fuzzySearch("programing", maxDistance = 2) # typo
|
|
check results.len >= 0 # may or may not match
|
|
|
|
test "Regex search with wildcard":
|
|
var idx = newInvertedIndex()
|
|
idx.addDocument(1, "fast database engine")
|
|
idx.addDocument(2, "slow query optimizer")
|
|
let results = idx.regexSearch("fast*")
|
|
check results.len >= 0
|
|
|
|
suite "Vector Metadata Filtering":
|
|
test "Search with metadata filter":
|
|
var idx = vengine.newHNSWIndex(3)
|
|
vengine.insert(idx, 1, @[1.0'f32, 0.0'f32, 0.0'f32],
|
|
{"category": "A", "region": "US"}.toTable)
|
|
vengine.insert(idx, 2, @[0.9'f32, 0.1'f32, 0.0'f32],
|
|
{"category": "B", "region": "EU"}.toTable)
|
|
vengine.insert(idx, 3, @[1.0'f32, 0.0'f32, 0.0'f32],
|
|
{"category": "A", "region": "EU"}.toTable)
|
|
|
|
# Filter: only category A
|
|
proc filterA(metadata: Table[string, string]): bool =
|
|
return metadata.getOrDefault("category", "") == "A"
|
|
|
|
let results = vengine.searchWithFilter(idx, @[1.0'f32, 0.0'f32, 0.0'f32], 10,
|
|
filter = filterA)
|
|
check results.len == 2 # only category A entries
|
|
|
|
suite "BaraQL Parser — Extended":
|
|
test "Parse GROUP BY":
|
|
let ast = parse("SELECT dept, count(*) FROM employees GROUP BY dept")
|
|
check ast.stmts.len == 1
|
|
check ast.stmts[0].selGroupBy.len == 1
|
|
|
|
test "Parse GROUP BY with HAVING":
|
|
let ast = parse("SELECT dept, count(*) FROM employees GROUP BY dept HAVING count(*) > 5")
|
|
check ast.stmts[0].selGroupBy.len == 1
|
|
check ast.stmts[0].selHaving != nil
|
|
|
|
test "Parse FILTER clause":
|
|
let ast = parse("SELECT COUNT(*) FILTER (WHERE active = true) FROM users")
|
|
check ast.stmts[0].selResult.len == 1
|
|
check ast.stmts[0].selResult[0].funcFilter != nil
|
|
|
|
test "Parse ROLLUP":
|
|
let ast = parse("SELECT dept, SUM(amount) FROM sales GROUP BY ROLLUP (dept)")
|
|
check ast.stmts[0].selGroupingSetsKind == gskRollup
|
|
check ast.stmts[0].selGroupBy.len == 1
|
|
|
|
test "Parse CUBE":
|
|
let ast = parse("SELECT dept, job, SUM(amount) FROM sales GROUP BY CUBE (dept, job)")
|
|
check ast.stmts[0].selGroupingSetsKind == gskCube
|
|
check ast.stmts[0].selGroupBy.len == 2
|
|
|
|
test "Parse GROUPING SETS":
|
|
let ast = parse("SELECT dept, job, SUM(amount) FROM sales GROUP BY GROUPING SETS ((dept), (job), ())")
|
|
check ast.stmts[0].selGroupingSetsKind == gskGroupingSets
|
|
check ast.stmts[0].selGroupingSets.len == 3
|
|
|
|
test "Parse PIVOT":
|
|
let ast = parse("SELECT * FROM (SELECT dept, salary FROM emp) PIVOT (SUM(salary) FOR dept IN ('Eng', 'Sales'))")
|
|
check ast.stmts[0].selFrom.kind == nkPivot
|
|
check ast.stmts[0].selFrom.pivotForCol == "dept"
|
|
check ast.stmts[0].selFrom.pivotInValues.len == 2
|
|
|
|
test "Parse GRAPH_TABLE":
|
|
let ast = parse("SELECT * FROM GRAPH_TABLE(org_chart MATCH (e)-[r]->(d) COLUMNS (e.name, d.name))")
|
|
check ast.stmts[0].selFrom.kind == nkGraphTraversal
|
|
check ast.stmts[0].selFrom.gtGraphName == "org_chart"
|
|
|
|
test "Parse ORDER BY with direction":
|
|
let ast = parse("SELECT name FROM users ORDER BY age DESC")
|
|
check ast.stmts[0].selOrderBy.len == 1
|
|
check ast.stmts[0].selOrderBy[0].orderByDir == sdDesc
|
|
|
|
test "Parse ORDER BY multiple columns":
|
|
let ast = parse("SELECT * FROM t ORDER BY a ASC, b DESC")
|
|
check ast.stmts[0].selOrderBy.len == 2
|
|
|
|
test "Parse INNER JOIN":
|
|
let ast = parse("SELECT u.name, o.total FROM users u INNER JOIN orders o ON u.id = o.user_id")
|
|
check ast.stmts[0].selJoins.len == 1
|
|
check ast.stmts[0].selJoins[0].joinKind == jkInner
|
|
|
|
test "Parse LEFT JOIN":
|
|
let ast = parse("SELECT u.name FROM users u LEFT JOIN orders o ON u.id = o.user_id")
|
|
check ast.stmts[0].selJoins.len == 1
|
|
check ast.stmts[0].selJoins[0].joinKind == jkLeft
|
|
|
|
test "Parse multiple JOINs":
|
|
let ast = parse("SELECT * FROM a JOIN b ON a.id = b.aid JOIN c ON b.id = c.bid")
|
|
check ast.stmts[0].selJoins.len == 2
|
|
|
|
test "Parse LATERAL JOIN":
|
|
let ast = parse("SELECT u.name, x.total FROM users u JOIN LATERAL (SELECT o.total FROM orders o WHERE o.user_id = u.id) AS x ON 1=1")
|
|
check ast.stmts[0].selJoins.len == 1
|
|
check ast.stmts[0].selJoins[0].joinLateral == true
|
|
check ast.stmts[0].selJoins[0].joinTarget.kind == nkSubquery
|
|
check ast.stmts[0].selJoins[0].joinAlias == "x"
|
|
|
|
test "Parse LEFT LATERAL JOIN":
|
|
let ast = parse("SELECT u.name FROM users u LEFT JOIN LATERAL (SELECT 1) AS x ON 1=1")
|
|
check ast.stmts[0].selJoins.len == 1
|
|
check ast.stmts[0].selJoins[0].joinKind == jkLeft
|
|
check ast.stmts[0].selJoins[0].joinLateral == true
|
|
|
|
test "Parse CROSS JOIN LATERAL":
|
|
let ast = parse("SELECT * FROM users u CROSS JOIN LATERAL (SELECT 1) AS x")
|
|
check ast.stmts[0].selJoins.len == 1
|
|
check ast.stmts[0].selJoins[0].joinKind == jkCross
|
|
check ast.stmts[0].selJoins[0].joinLateral == true
|
|
|
|
test "Parse CTE (WITH)":
|
|
let ast = parse("WITH active AS (SELECT * FROM users WHERE active = true) SELECT * FROM active")
|
|
check ast.stmts[0].selWith.len == 1
|
|
check ast.stmts[0].selWith[0][0] == "active"
|
|
check ast.stmts[0].selWith[0][2] == false
|
|
|
|
test "Parse multiple CTEs":
|
|
let ast = parse("WITH a AS (SELECT id FROM t1), b AS (SELECT id FROM t2) SELECT * FROM a")
|
|
check ast.stmts[0].selWith.len == 2
|
|
check ast.stmts[0].selWith[0][2] == false
|
|
|
|
test "Parse aggregate functions in SELECT":
|
|
let ast = parse("SELECT count(*), sum(amount), avg(price), min(age), max(score) FROM orders")
|
|
check ast.stmts[0].selResult.len == 5
|
|
|
|
test "Parse CASE expression":
|
|
let ast = parse("SELECT CASE WHEN age > 18 THEN 'adult' ELSE 'minor' END FROM users")
|
|
check ast.stmts[0].selResult.len == 1
|
|
|
|
test "Parse BETWEEN":
|
|
let ast = parse("SELECT * FROM products WHERE price BETWEEN 10 AND 100")
|
|
check ast.stmts[0].selWhere != nil
|
|
|
|
test "Parse subquery in FROM":
|
|
let ast = parse("SELECT * FROM (SELECT id FROM users) AS sub")
|
|
check ast.stmts[0].selFrom != nil
|
|
|
|
test "Parse UPDATE SET WHERE":
|
|
let ast = parse("UPDATE users SET name = 'Alice' WHERE id = 1")
|
|
check ast.stmts[0].updSet.len == 1
|
|
|
|
test "Parse DELETE WHERE":
|
|
let ast = parse("DELETE FROM users WHERE id = 1")
|
|
check ast.stmts[0].delWhere != nil
|
|
|
|
test "Parse CREATE TYPE with properties":
|
|
let ast = parse("CREATE TYPE Person { name: str, age: int32 }")
|
|
check ast.stmts[0].ctName == "Person"
|
|
check ast.stmts[0].ctProperties.len == 2
|
|
|
|
suite "Raft Consensus":
|
|
test "Create cluster with nodes":
|
|
var cluster = newRaftCluster()
|
|
cluster.addNode("n1")
|
|
cluster.addNode("n2")
|
|
cluster.addNode("n3")
|
|
check cluster.nodes.len == 3
|
|
check cluster.nodes["n1"].peers.len == 2
|
|
|
|
test "Initial state is follower":
|
|
var cluster = newRaftCluster()
|
|
cluster.addNode("n1")
|
|
check cluster.nodes["n1"].state == rsFollower
|
|
|
|
test "Election — single node becomes leader":
|
|
var cluster = newRaftCluster()
|
|
cluster.addNode("n1")
|
|
let node = cluster.nodes["n1"]
|
|
node.becomeCandidate()
|
|
node.becomeLeader()
|
|
check node.isLeader
|
|
check node.leaderId == "n1"
|
|
|
|
test "Log replication":
|
|
var cluster = newRaftCluster()
|
|
cluster.addNode("n1")
|
|
let node = cluster.nodes["n1"]
|
|
node.becomeCandidate()
|
|
node.becomeLeader()
|
|
let entry = node.appendLog("SET key1 value1")
|
|
check entry.index == 1
|
|
check node.logLen == 1
|
|
let entry2 = node.appendLog("SET key2 value2")
|
|
check entry2.index == 2
|
|
check node.logLen == 2
|
|
|
|
test "RequestVote handling":
|
|
var cluster = newRaftCluster()
|
|
cluster.addNode("n1")
|
|
cluster.addNode("n2")
|
|
let n1 = cluster.nodes["n1"]
|
|
let n2 = cluster.nodes["n2"]
|
|
let req = RaftMessage(kind: rmkRequestVote, term: 1, senderId: "n2",
|
|
lastLogIndex: 0, lastLogTerm: 0)
|
|
let reply = n1.handleRequestVote(req)
|
|
check reply.success
|
|
check n1.votedFor == "n2"
|
|
|
|
test "AppendEntries handling":
|
|
var cluster = newRaftCluster()
|
|
cluster.addNode("n1")
|
|
cluster.addNode("n2")
|
|
let n2 = cluster.nodes["n2"]
|
|
let msg = RaftMessage(kind: rmkAppendEntries, term: 1, senderId: "n1",
|
|
prevLogIndex: 0, prevLogTerm: 0,
|
|
entries: @[LogEntry(term: 1, index: 1, command: "SET x 1")],
|
|
leaderCommit: 0)
|
|
let reply = n2.handleAppendEntries(msg)
|
|
check reply.success
|
|
check n2.logLen == 1
|
|
|
|
suite "Sharding":
|
|
test "Hash-based sharding":
|
|
var router = newShardRouter(ShardConfig(numShards: 4, strategy: ssHash))
|
|
check router.shardCount == 4
|
|
let s1 = router.getShard("user_1")
|
|
let s2 = router.getShard("user_2")
|
|
check s1 >= 0 and s1 < 4
|
|
check s2 >= 0 and s2 < 4
|
|
|
|
test "Consistent hashing":
|
|
var router = newShardRouter(ShardConfig(numShards: 4, strategy: ssConsistent))
|
|
router.addVirtualNodes(50)
|
|
let s = router.getShard("some_key")
|
|
check s >= 0 and s < 4
|
|
|
|
test "Range-based sharding":
|
|
var router = newShardRouter(ShardConfig(numShards: 3, strategy: ssRange))
|
|
router.setRangeBounds(@[("a", "f"), ("g", "n"), ("o", "z")])
|
|
check router.getShardRange("apple") == 0
|
|
check router.getShardRange("hello") == 1
|
|
check router.getShardRange("top") == 2
|
|
|
|
test "Rebalance assigns nodes":
|
|
var router = newShardRouter(ShardConfig(numShards: 3, replicas: 2, strategy: ssHash))
|
|
router.rebalance(@["node1", "node2", "node3"])
|
|
for shard in router.shards:
|
|
check shard.nodeIds.len == 2 # 2 replicas
|
|
|
|
test "Replicas of key":
|
|
var router = newShardRouter(ShardConfig(numShards: 2, replicas: 1, strategy: ssHash))
|
|
router.rebalance(@["n1", "n2"])
|
|
let replicas = router.replicasOf("test_key")
|
|
check replicas.len == 1
|
|
|
|
test "Active shard count":
|
|
var router = newShardRouter()
|
|
check router.activeShardCount == 4
|
|
|
|
suite "Schema Inheritance":
|
|
test "Inheritance — merge properties from base":
|
|
var s = newSchema()
|
|
let base = newType("Base")
|
|
base.addProperty("id", "str", required = true)
|
|
base.addProperty("created", "datetime")
|
|
s.addType("default", base)
|
|
|
|
let child = newType("Person")
|
|
child.setBases(@["Base"])
|
|
child.addProperty("name", "str", required = true)
|
|
s.addType("default", child)
|
|
|
|
let resolved = s.resolveInheritance(child)
|
|
check resolved.properties.len == 3 # id + created + name
|
|
check "id" in resolved.properties
|
|
check "name" in resolved.properties
|
|
check "created" in resolved.properties
|
|
|
|
test "Multi-level inheritance":
|
|
var s = newSchema()
|
|
let a = newType("A")
|
|
a.addProperty("a1", "str")
|
|
s.addType("default", a)
|
|
|
|
let b = newType("B")
|
|
b.setBases(@["A"])
|
|
b.addProperty("b1", "int32")
|
|
s.addType("default", b)
|
|
|
|
let c = newType("C")
|
|
c.setBases(@["B"])
|
|
c.addProperty("c1", "bool")
|
|
s.addType("default", c)
|
|
|
|
let resolved = s.resolveInheritance(c)
|
|
check resolved.properties.len == 3 # a1 + b1 + c1
|
|
|
|
test "Override base property":
|
|
var s = newSchema()
|
|
let base = newType("Base")
|
|
base.addProperty("name", "str")
|
|
s.addType("default", base)
|
|
|
|
let child = newType("Child")
|
|
child.setBases(@["Base"])
|
|
child.addProperty("name", "text") # override
|
|
s.addType("default", child)
|
|
|
|
let resolved = s.resolveInheritance(child)
|
|
check resolved.properties["name"].typeName == "text"
|
|
|
|
test "isSubtype":
|
|
var s = newSchema()
|
|
let a = newType("A")
|
|
s.addType("default", a)
|
|
let b = newType("B")
|
|
b.setBases(@["A"])
|
|
s.addType("default", b)
|
|
let c = newType("C")
|
|
c.setBases(@["B"])
|
|
s.addType("default", c)
|
|
|
|
check s.isSubtype("C", "A")
|
|
check s.isSubtype("C", "B")
|
|
check s.isSubtype("B", "A")
|
|
check not s.isSubtype("A", "C")
|
|
|
|
test "Computed property":
|
|
let t = newType("Person")
|
|
t.addProperty("firstName", "str")
|
|
t.addProperty("lastName", "str")
|
|
t.addComputedProperty("fullName", "str", "firstName ++ ' ' ++ lastName")
|
|
check t.properties["fullName"].computed
|
|
check t.properties["fullName"].expr == "firstName ++ ' ' ++ lastName"
|
|
|
|
suite "Codegen":
|
|
test "Codegen scan":
|
|
let plan = IRPlan(kind: irpkScan, scanTable: "users", scanAlias: "u")
|
|
let op = codegenPlan(plan)
|
|
check op.kind == sokScan
|
|
check op.table == "users"
|
|
|
|
test "Codegen filter with point read optimization":
|
|
let filterExpr = IRExpr(kind: irekBinary, binOp: irEq,
|
|
binLeft: IRExpr(kind: irekField, fieldPath: @["id"]),
|
|
binRight: IRExpr(kind: irekLiteral, literal: IRLiteral(kind: vkInt64, int64Val: 42)))
|
|
let scan = IRPlan(kind: irpkScan, scanTable: "users", scanAlias: "u")
|
|
let plan = IRPlan(kind: irpkFilter, filterSource: scan, filterCond: filterExpr)
|
|
let op = codegenPlan(plan)
|
|
# Should optimize to point read
|
|
check op.kind == sokPointRead
|
|
|
|
test "Codegen limit":
|
|
let scan = IRPlan(kind: irpkScan, scanTable: "t", scanAlias: "t")
|
|
let plan = IRPlan(kind: irpkLimit, limitSource: scan, limitCount: 10, limitOffset: 5)
|
|
let op = codegenPlan(plan)
|
|
check op.limit == 10
|
|
check op.offset == 5
|
|
|
|
test "Cost estimation":
|
|
let scan = newStorageOp(sokScan)
|
|
check estimateCost(scan) == 1000.0
|
|
let pointRead = newStorageOp(sokPointRead)
|
|
check estimateCost(pointRead) == 1.0
|
|
|
|
test "Explain plan":
|
|
let scan = newStorageOp(sokScan)
|
|
scan.table = "users"
|
|
let explanation = scan.explain()
|
|
check "sokScan" in explanation
|
|
check "users" in explanation
|
|
|
|
suite "Replication":
|
|
test "Create replication manager":
|
|
var rm = newReplicationManager(rmAsync)
|
|
check rm.totalReplicaCount == 0
|
|
check rm.connectedReplicaCount == 0
|
|
|
|
test "Add and connect replicas":
|
|
var rm = newReplicationManager(rmAsync)
|
|
rm.addReplica(newReplica("r1", "10.0.0.1", 9472))
|
|
rm.addReplica(newReplica("r2", "10.0.0.2", 9472))
|
|
check rm.totalReplicaCount == 2
|
|
|
|
rm.connectReplica("r1")
|
|
check rm.connectedReplicaCount == 1
|
|
|
|
test "Async replication — write returns immediately":
|
|
var rm = newReplicationManager(rmAsync)
|
|
rm.addReplica(newReplica("r1", "10.0.0.1", 9472))
|
|
rm.connectReplica("r1")
|
|
|
|
let lsn = rm.writeLsn(@[1'u8, 2, 3])
|
|
check lsn == 1
|
|
# Async doesn't wait — already "acked"
|
|
check rm.isFullyAcked(lsn)
|
|
|
|
test "Sync replication — wait for ack":
|
|
var rm = newReplicationManager(rmSync)
|
|
rm.addReplica(newReplica("r1", "10.0.0.1", 9472))
|
|
rm.connectReplica("r1")
|
|
|
|
let lsn = rm.writeLsn(@[1'u8, 2, 3])
|
|
check not rm.isFullyAcked(lsn)
|
|
|
|
rm.ackLsn("r1", lsn)
|
|
check rm.isFullyAcked(lsn)
|
|
|
|
test "Semi-sync replication":
|
|
var rm = newReplicationManager(rmSemiSync, syncCount = 2)
|
|
rm.addReplica(newReplica("r1", "10.0.0.1", 9472))
|
|
rm.addReplica(newReplica("r2", "10.0.0.2", 9472))
|
|
rm.addReplica(newReplica("r3", "10.0.0.3", 9472))
|
|
rm.connectReplica("r1")
|
|
rm.connectReplica("r2")
|
|
rm.connectReplica("r3")
|
|
|
|
let lsn = rm.writeLsn(@[1'u8])
|
|
check not rm.isFullyAcked(lsn) # needs 2 acks
|
|
|
|
rm.ackLsn("r1", lsn)
|
|
check not rm.isFullyAcked(lsn) # still needs 1 more
|
|
|
|
rm.ackLsn("r2", lsn)
|
|
check rm.isFullyAcked(lsn) # 2 acks received
|
|
|
|
test "Replica status":
|
|
var rm = newReplicationManager(rmAsync)
|
|
rm.addReplica(newReplica("r1", "10.0.0.1", 9472))
|
|
rm.connectReplica("r1")
|
|
let status = rm.replicaStatus()
|
|
check status.len == 1
|
|
check status[0][1] == rsStreaming
|
|
|
|
suite "User Defined Functions":
|
|
test "Register and call UDF":
|
|
var reg = newUDFRegistry()
|
|
reg.register("double", @[UDFParam(name: "x", typeName: "int64", required: true)],
|
|
"int64", proc(args: seq[Value]): Value =
|
|
if args.len > 0 and args[0].kind == vkInt64:
|
|
return Value(kind: vkInt64, int64Val: args[0].int64Val * 2)
|
|
return Value(kind: vkNull))
|
|
|
|
check reg.hasFunction("double")
|
|
let result = reg.call("double", @[Value(kind: vkInt64, int64Val: 21)])
|
|
check result.kind == vkInt64
|
|
check result.int64Val == 42
|
|
|
|
test "Register expression-based UDF":
|
|
var reg = newUDFRegistry()
|
|
reg.registerExpr("greet", @[UDFParam(name: "name", typeName: "str")],
|
|
"str", "'Hello ' ++ name")
|
|
check reg.hasFunction("greet")
|
|
check reg.getFunction("greet").expr == "'Hello ' ++ name"
|
|
|
|
test "Standard library functions":
|
|
var reg = newUDFRegistry()
|
|
reg.registerStdlib()
|
|
|
|
# lower
|
|
let r1 = reg.call("lower", @[Value(kind: vkString, strVal: "HELLO")])
|
|
check r1.strVal == "hello"
|
|
|
|
# upper
|
|
let r2 = reg.call("upper", @[Value(kind: vkString, strVal: "hello")])
|
|
check r2.strVal == "HELLO"
|
|
|
|
# len
|
|
let r3 = reg.call("len", @[Value(kind: vkString, strVal: "test")])
|
|
check r3.int64Val == 4
|
|
|
|
# trim
|
|
let r4 = reg.call("trim", @[Value(kind: vkString, strVal: " hello ")])
|
|
check r4.strVal == "hello"
|
|
|
|
# toString
|
|
let r5 = reg.call("toString", @[Value(kind: vkInt64, int64Val: 42)])
|
|
check r5.strVal == "42"
|
|
|
|
test "Deregister function":
|
|
var reg = newUDFRegistry()
|
|
reg.register("temp", @[], "int64", proc(args: seq[Value]): Value = Value(kind: vkNull))
|
|
check reg.hasFunction("temp")
|
|
reg.deregister("temp")
|
|
check not reg.hasFunction("temp")
|
|
|
|
test "Function count":
|
|
var reg = newUDFRegistry()
|
|
reg.registerStdlib()
|
|
check reg.functionCount > 10
|
|
|
|
suite "Vector SIMD":
|
|
test "Dot product":
|
|
let a = @[1.0'f32, 2.0'f32, 3.0'f32]
|
|
let b = @[4.0'f32, 5.0'f32, 6.0'f32]
|
|
let result = dotProductSimd(a, b)
|
|
check abs(result - 32.0) < 0.001
|
|
|
|
test "L2 distance":
|
|
let a = @[0.0'f32, 0.0'f32]
|
|
let b = @[3.0'f32, 4.0'f32]
|
|
let result = l2NormSimd(a, b)
|
|
check abs(result - 5.0) < 0.001
|
|
|
|
test "Cosine distance":
|
|
let a = @[1.0'f32, 0.0'f32, 0.0'f32]
|
|
let b = @[0.0'f32, 1.0'f32, 0.0'f32]
|
|
let result = cosineSimd(a, b)
|
|
check abs(result - 1.0) < 0.001 # orthogonal = 1.0
|
|
|
|
let c = @[1.0'f32, 0.0'f32, 0.0'f32]
|
|
let d = @[1.0'f32, 0.0'f32, 0.0'f32]
|
|
check cosineSimd(c, d) < 0.001 # same direction = 0.0
|
|
|
|
test "Manhattan distance":
|
|
let a = @[1.0'f32, 2.0'f32]
|
|
let b = @[4.0'f32, 6.0'f32]
|
|
let result = manhattanSimd(a, b)
|
|
check abs(result - 7.0) < 0.001
|
|
|
|
test "Normalize vector":
|
|
let v = @[3.0'f32, 4.0'f32]
|
|
let n = normalize(v)
|
|
check abs(n[0] - 0.6) < 0.001
|
|
check abs(n[1] - 0.8) < 0.001
|
|
|
|
test "Add vectors":
|
|
let a = @[1.0'f32, 2.0'f32]
|
|
let b = @[3.0'f32, 4.0'f32]
|
|
let c = addVectors(a, b)
|
|
check c[0] == 4.0
|
|
check c[1] == 6.0
|
|
|
|
test "Scale vector":
|
|
let v = @[1.0'f32, 2.0'f32, 3.0'f32]
|
|
let s = scaleVector(v, 2.0)
|
|
check s[0] == 2.0
|
|
check s[1] == 4.0
|
|
check s[2] == 6.0
|
|
|
|
test "TopK":
|
|
let distances = @[5.0'f32, 1.0'f32, 3.0'f32, 2.0'f32, 4.0'f32]
|
|
let top = topK(distances, 3)
|
|
check top.len == 3
|
|
check top[0][0] == 1 # index 1, value 1.0
|
|
check top[1][0] == 3 # index 3, value 2.0
|
|
check top[2][0] == 2 # index 2, value 3.0
|
|
|
|
test "Batch distance":
|
|
let queries = @[@[1.0'f32, 0.0'f32], @[0.0'f32, 1.0'f32]]
|
|
let corpus = @[@[1.0'f32, 0.0'f32], @[0.0'f32, 1.0'f32], @[1.0'f32, 1.0'f32]]
|
|
let results = batchDistance(queries, corpus, "cosine")
|
|
check results.len == 2
|
|
check results[0].len == 3
|
|
|
|
suite "Cross-Modal Engine":
|
|
test "Create engine":
|
|
let engine = newCrossModalEngine("/tmp/baradb_test_crossmodal")
|
|
check engine != nil
|
|
|
|
test "Document operations":
|
|
let engine = newCrossModalEngine("/tmp/baradb_test_crossmodal2")
|
|
engine.put("key1", cast[seq[byte]]("value1"))
|
|
let (found, val) = engine.get("key1")
|
|
check found
|
|
check cast[string](val) == "value1"
|
|
|
|
test "Vector operations":
|
|
let engine = newCrossModalEngine("/tmp/baradb_test_crossmodal3")
|
|
engine.insertVector(1, @[1.0'f32, 0.0'f32, 0.0'f32], {"cat": "A"}.toTable)
|
|
engine.insertVector(2, @[0.0'f32, 1.0'f32, 0.0'f32], {"cat": "B"}.toTable)
|
|
let results = engine.searchVector(@[1.0'f32, 0.1'f32, 0.0'f32], 2)
|
|
check results.len == 2
|
|
|
|
test "Graph operations":
|
|
let engine = newCrossModalEngine("/tmp/baradb_test_crossmodal4")
|
|
let n1 = engine.addNode("Person")
|
|
let n2 = engine.addNode("Person")
|
|
discard engine.addEdge(n1, n2, "knows")
|
|
let traversal = engine.traverseGraph(n1, "bfs")
|
|
check traversal.len >= 1
|
|
|
|
test "FTS operations":
|
|
let engine = newCrossModalEngine("/tmp/baradb_test_crossmodal5")
|
|
engine.indexText(1, "Nim programming language")
|
|
engine.indexText(2, "Python data science")
|
|
let results = engine.searchText("programming")
|
|
check results.len >= 1
|
|
|
|
test "2PC transaction":
|
|
var txn = newTPCTransaction(1)
|
|
txn.addParticipant("storage")
|
|
txn.addParticipant("vector")
|
|
txn.addParticipant("graph")
|
|
check txn.participantCount == 3
|
|
check txn.prepare()
|
|
check txn.isPrepared
|
|
check txn.commit()
|
|
check txn.isCommitted
|
|
|
|
test "2PC rollback":
|
|
var txn = newTPCTransaction(2)
|
|
txn.addParticipant("storage")
|
|
txn.addParticipant("vector")
|
|
check txn.prepare()
|
|
check txn.rollback()
|
|
check txn.isAborted
|
|
|
|
test "Hybrid query":
|
|
let engine = newCrossModalEngine("/tmp/baradb_test_crossmodal6")
|
|
engine.insertVector(1, @[1.0'f32, 0.0'f32], {"cat": "A"}.toTable)
|
|
engine.indexText(1, "fast database")
|
|
var query = newCrossModalQuery(qmHybrid)
|
|
query.vector = @[1.0'f32, 0.0'f32]
|
|
query.vectorK = 5
|
|
query.searchQuery = "fast"
|
|
query.vecWeight = 1.0
|
|
query.ftsWeight = 1.0
|
|
let result = engine.hybridSearch(query)
|
|
check result.totalResults >= 0
|
|
|
|
suite "Gossip Protocol":
|
|
test "Create gossip node":
|
|
var gp = newGossipProtocol("node1", "10.0.0.1", 7946)
|
|
check gp.self.id == "node1"
|
|
check gp.memberCount == 0
|
|
|
|
test "Add members":
|
|
var gp = newGossipProtocol("node1", "10.0.0.1", 7946)
|
|
let node2 = newGossipNode("node2", "10.0.0.2", 7946)
|
|
let node3 = newGossipNode("node3", "10.0.0.3", 7946)
|
|
gp.addMember(node2)
|
|
gp.addMember(node3)
|
|
check gp.memberCount == 2
|
|
check gp.aliveCount == 2
|
|
|
|
test "Suspect and declare dead":
|
|
var gp = newGossipProtocol("node1", "10.0.0.1", 7946)
|
|
let node2 = newGossipNode("node2", "10.0.0.2", 7946)
|
|
gp.addMember(node2)
|
|
gp.suspect("node2")
|
|
check gp.getMember("node2").state == nsSuspect
|
|
gp.declareDead("node2")
|
|
check gp.memberCount == 1
|
|
|
|
test "Gossip message":
|
|
var gp = newGossipProtocol("node1", "10.0.0.1", 7946)
|
|
let node2 = newGossipNode("node2", "10.0.0.2", 7946)
|
|
gp.addMember(node2)
|
|
let msg = gp.createGossipMessage()
|
|
check msg.senderId == "node1"
|
|
check msg.nodes.len == 1
|
|
|
|
test "Select gossip targets":
|
|
var gp = newGossipProtocol("node1", "10.0.0.1", 7946, fanout = 2)
|
|
for i in 2..5:
|
|
gp.addMember(newGossipNode("node" & $i, "10.0.0." & $i, 7946))
|
|
let targets = gp.selectGossipTargets()
|
|
check targets.len <= 2
|
|
|
|
test "Member IDs":
|
|
var gp = newGossipProtocol("node1", "10.0.0.1", 7946)
|
|
gp.addMember(newGossipNode("node2", "10.0.0.2", 7946))
|
|
check gp.isMember("node2")
|
|
check not gp.isMember("node99")
|
|
|
|
suite "Client Library":
|
|
test "Connection string parser":
|
|
let config = parseConnectionString("host=localhost port=9472 dbname=test user=admin")
|
|
check config.host == "localhost"
|
|
check config.port == 9472
|
|
check config.database == "test"
|
|
check config.username == "admin"
|
|
|
|
test "Client config defaults":
|
|
let config = defaultClientConfig()
|
|
check config.host == "127.0.0.1"
|
|
check config.port == 9472
|
|
|
|
test "Query builder":
|
|
let client = newBaraClient()
|
|
let qb = newQueryBuilder(client)
|
|
let sql = qb.select("name", "age").from("users")
|
|
.where("age > 18").orderBy("name", "ASC").limit(10).build()
|
|
check sql == "SELECT name, age FROM users WHERE age > 18 ORDER BY name ASC LIMIT 10"
|
|
|
|
test "Query builder with JOIN":
|
|
let client = newBaraClient()
|
|
let qb = newQueryBuilder(client)
|
|
let sql = qb.select("u.name", "o.total")
|
|
.from("users u").join("orders o", "u.id = o.user_id")
|
|
.where("o.total > 100").build()
|
|
check "JOIN" in sql
|
|
check "WHERE" in sql
|
|
|
|
test "Query builder with GROUP BY":
|
|
let client = newBaraClient()
|
|
let qb = newQueryBuilder(client)
|
|
let sql = qb.select("dept", "count(*)").from("employees")
|
|
.groupBy("dept").having("count(*) > 5").build()
|
|
check "GROUP BY" in sql
|
|
check "HAVING" in sql
|
|
|
|
suite "Import/Export":
|
|
test "JSON export":
|
|
let columns = @["name", "age"]
|
|
let rows = @[@["Alice", "30"], @["Bob", "25"]]
|
|
let json = fileops.toJson(columns, rows)
|
|
check json.startsWith("[")
|
|
check "Alice" in json
|
|
|
|
test "CSV export":
|
|
let columns = @["name", "age"]
|
|
let rows = @[@["Alice", "30"], @["Bob", "25"]]
|
|
let csv = fileops.toCsv(columns, rows)
|
|
check csv.startsWith("name,age")
|
|
check "Alice" in csv
|
|
|
|
test "JSON import":
|
|
let json = """[{"name": "Alice", "age": "30"}, {"name": "Bob", "age": "25"}]"""
|
|
let (columns, rows) = fileops.parseJsonTable(json)
|
|
check columns.len == 2
|
|
check rows.len == 2
|
|
|
|
test "CSV import":
|
|
let csv = "name,age\nAlice,30\nBob,25"
|
|
let (columns, rows) = fileops.parseCsvTable(csv)
|
|
check columns.len == 2
|
|
check rows.len == 2
|
|
check rows[0][0] == "Alice"
|
|
|
|
test "NDJSON export/import":
|
|
let columns = @["name", "age"]
|
|
let rows = @[@["Alice", "30"]]
|
|
let ndjson = fileops.toNdjson(columns, rows)
|
|
check "Alice" in ndjson
|
|
|
|
test "CSV with quoted fields":
|
|
let csv = "name,bio\nAlice,\"Software engineer, Nim\"\nBob,Data scientist"
|
|
let (columns, rows) = fileops.parseCsvTable(csv)
|
|
check rows.len == 2
|
|
|
|
suite "Multi-Language FTS":
|
|
test "English tokenizer":
|
|
let config = mlang.getLanguageConfig(mlang.langEnglish)
|
|
let tokens = mlang.tokenize("The quick brown fox jumps over the lazy dog", config)
|
|
check tokens.len > 0
|
|
check "the" notin tokens # stop word
|
|
|
|
test "Bulgarian tokenizer":
|
|
let config = mlang.getLanguageConfig(mlang.langBulgarian)
|
|
let tokens = mlang.tokenize("Бързата кафява лисица прескача мързеливото куче", config)
|
|
check tokens.len > 0
|
|
|
|
test "German tokenizer":
|
|
let config = mlang.getLanguageConfig(mlang.langGerman)
|
|
let tokens = mlang.tokenize("Der schnelle braune Fuchs springt über den faulen Hund", config)
|
|
check tokens.len > 0
|
|
check "der" notin tokens
|
|
|
|
test "Russian tokenizer":
|
|
let config = mlang.getLanguageConfig(mlang.langRussian)
|
|
let tokens = mlang.tokenize("Быстрая браун лиса прыгает через ленивую собаку", config)
|
|
check tokens.len > 0
|
|
|
|
test "Language detection":
|
|
check mlang.detectLanguage("Hello world how are you") == mlang.langEnglish
|
|
# Bulgarian text is also Cyrillic — detected as Russian by default
|
|
check mlang.detectLanguage("Здравей свят как си") == mlang.langRussian
|
|
|
|
test "English stemming":
|
|
check mlang.stemEnglish("running") == "runn"
|
|
check mlang.stemEnglish("cats") == "cat"
|
|
check mlang.stemEnglish("programming") == "programm"
|
|
|
|
test "Bulgarian stemming":
|
|
check mlang.stemBulgarian("красота") == "красо"
|
|
|
|
suite "Zero-Copy Serialization":
|
|
test "Write and read int32":
|
|
var buf = newZeroBuf(64)
|
|
buf.writeInt32(42)
|
|
check buf.readInt32(0) == 42
|
|
buf.free()
|
|
|
|
test "Write and read int64":
|
|
var buf = newZeroBuf(64)
|
|
buf.writeInt64(12345)
|
|
check buf.readInt64(0) == 12345
|
|
buf.free()
|
|
|
|
test "Write and read bool":
|
|
var buf = newZeroBuf(64)
|
|
buf.writeBool(true)
|
|
check buf.readBool(0)
|
|
buf.free()
|
|
|
|
test "ZcSchema field offsets":
|
|
var schema = newZcSchema("user")
|
|
schema.addField("id", ztInt64)
|
|
schema.addField("name", ztString)
|
|
check schema.fields.len == 2
|
|
check schema.totalSize > 0
|
|
|
|
test "Encode and decode record":
|
|
var schema = newZcSchema("user")
|
|
schema.addField("id", ztInt32)
|
|
var buf = newZeroBuf(schema.totalSize)
|
|
buf.pos = schema.totalSize # pretend we wrote
|
|
buf.encodeRecord(schema, {"id": "42"}.toTable)
|
|
# Reset pos for reading at offsets
|
|
var pos = 0
|
|
let row = buf.decodeRecord(schema)
|
|
check row["id"] == "42"
|
|
buf.free()
|
|
|
|
test "ZcTable batch operations":
|
|
var schema = newZcSchema("user")
|
|
schema.addField("id", ztInt32)
|
|
var table = newZcTable(schema)
|
|
var buf1 = newZeroBuf(schema.totalSize)
|
|
buf1.encodeRecord(schema, {"id": "1"}.toTable)
|
|
table.records.add(buf1)
|
|
var buf2 = newZeroBuf(schema.totalSize)
|
|
buf2.encodeRecord(schema, {"id": "2"}.toTable)
|
|
table.records.add(buf2)
|
|
table.totalRows = 2
|
|
check table.totalRows == 2
|
|
check table.getRecord(1)["id"] == "2"
|
|
for i in 0..<table.records.len:
|
|
table.records[i].free()
|
|
|
|
suite "Adaptive Query Execution":
|
|
test "Cardinality estimation":
|
|
var planner = newAdaptivePlanner()
|
|
planner.updateCardinality("users", 500)
|
|
check planner.estimateRows("users") == 500
|
|
|
|
test "Should reoptimize":
|
|
var planner = newAdaptivePlanner()
|
|
check planner.shouldReoptimize(100, 500) # 5x more
|
|
check not planner.shouldReoptimize(100, 200) # 2x more (below threshold)
|
|
|
|
test "Plan cache":
|
|
var planner = newAdaptivePlanner()
|
|
let plan = QueryPlan(estimatedCost: 10.0, estimatedRows: 100)
|
|
planner.cachePlan("SELECT * FROM users", plan)
|
|
check planner.cacheSize == 1
|
|
let cached = planner.getCachedPlan("SELECT * FROM users")
|
|
check cached != nil
|
|
|
|
test "Execution context parallelization":
|
|
var ctx = newExecutionContext(enScan)
|
|
ctx.table = "big_table"
|
|
ctx.parallelHint = ParallelHint(canParallelize: true, estimatedPartitions: 4, dataSize: 10_000_000)
|
|
check ctx.canParallelize()
|
|
check ctx.estimateParallelism(8) == 4
|
|
|
|
test "Execution plan explain":
|
|
var root = newExecutionContext(enScan)
|
|
root.table = "users"
|
|
root.estimatedRows = 1000
|
|
var filter = newExecutionContext(enFilter)
|
|
filter.estimatedRows = 200
|
|
root.addChild(filter)
|
|
let plan = root.explain()
|
|
check "enScan" in plan
|
|
check "users" in plan
|
|
|
|
suite "Distributed Transactions":
|
|
test "Create distributed transaction":
|
|
var txn = newDistributedTransaction("coordinator")
|
|
txn.addParticipant("node1")
|
|
txn.addParticipant("node2")
|
|
check txn.participantCount == 2
|
|
|
|
test "Two-phase commit flow":
|
|
var txn = newDistributedTransaction("coordinator")
|
|
txn.addParticipant("n1")
|
|
check txn.prepare()
|
|
check txn.state() == dtsPrepared
|
|
check txn.commit()
|
|
check txn.isCommitted
|
|
|
|
test "Rollback dist transaction":
|
|
var txn = newDistributedTransaction("coordinator")
|
|
txn.addParticipant("n1")
|
|
check txn.rollback()
|
|
check txn.isAborted
|
|
|
|
test "DistTxnManager lifecycle":
|
|
var tm = newDistTxnManager()
|
|
let txn = tm.beginTransaction("node1")
|
|
check tm.activeCount == 1
|
|
txn.addParticipant("n2")
|
|
check txn.prepare()
|
|
check txn.commit()
|
|
tm.cleanupCompleted()
|
|
check tm.activeCount == 0
|
|
|
|
test "Saga pattern":
|
|
var saga = newSaga()
|
|
var executeCount = 0
|
|
var compensateCount = 0
|
|
|
|
saga.addStep(SagaStep(
|
|
name: "step1", nodeId: "n1",
|
|
execute: proc(): bool =
|
|
inc executeCount
|
|
return true,
|
|
compensate: proc() =
|
|
inc compensateCount))
|
|
|
|
saga.addStep(SagaStep(
|
|
name: "step2", nodeId: "n2",
|
|
execute: proc(): bool =
|
|
inc executeCount
|
|
return false, # fails!
|
|
compensate: proc() =
|
|
inc compensateCount))
|
|
|
|
check not saga.execute() # should fail at step2
|
|
check executeCount == 2
|
|
check compensateCount == 1 # step1 compensated
|
|
|
|
suite "Vector Batch Operations":
|
|
test "Batch insert HNSW":
|
|
var idx = vengine.newHNSWIndex(3)
|
|
let batch = @[
|
|
(1'u64, @[1.0'f32, 0.0'f32, 0.0'f32]),
|
|
(2'u64, @[0.0'f32, 1.0'f32, 0.0'f32]),
|
|
(3'u64, @[0.0'f32, 0.0'f32, 1.0'f32]),
|
|
]
|
|
vengine.batchInsert(idx, batch)
|
|
check vengine.len(idx) == 3
|
|
|
|
test "Batch search":
|
|
var idx = vengine.newHNSWIndex(3)
|
|
vengine.batchInsert(idx, @[
|
|
(1'u64, @[1.0'f32, 0.0'f32, 0.0'f32]),
|
|
(2'u64, @[0.0'f32, 1.0'f32, 0.0'f32]),
|
|
])
|
|
let queries = @[@[1.0'f32, 0.0'f32, 0.0'f32], @[0.0'f32, 1.0'f32, 0.0'f32]]
|
|
let results = vengine.batchSearch(idx, queries, 2)
|
|
check results.len == 2
|
|
|
|
test "Index watcher auto-rebuild":
|
|
var watcher = newIndexWatcher(RebuildConfig(
|
|
maxUnindexedCount: 3, autoRebuild: true,
|
|
checkInterval: 0, rebuildThreshold: 0.5,
|
|
))
|
|
watcher.trackUnindexed(5) # 5 unindexed
|
|
check watcher.shouldRebuild()
|
|
watcher.markRebuilt()
|
|
let (total, unindexed, rebuilds) = watcher.stats()
|
|
check unindexed == 0
|
|
check rebuilds == 1
|
|
|
|
test "Rebuild threshold by ratio":
|
|
var watcher = newIndexWatcher(RebuildConfig(
|
|
autoRebuild: true, rebuildThreshold: 0.3,
|
|
))
|
|
for i in 0..<100:
|
|
watcher.trackInsert()
|
|
watcher.trackUnindexed(40) # 40% unindexed
|
|
check watcher.shouldRebuild()
|
|
|
|
suite "Cluster Auto-Rebalance":
|
|
test "Add node triggers rebalance":
|
|
var router = newShardRouter(ShardConfig(numShards: 4, replicas: 2))
|
|
var cm = newClusterMembership(router)
|
|
cm.addNode("node1")
|
|
cm.addNode("node2")
|
|
cm.addNode("node3")
|
|
check cm.nodeCount == 3
|
|
|
|
test "Remove node triggers rebalance":
|
|
var router = newShardRouter(ShardConfig(numShards: 4, replicas: 1))
|
|
var cm = newClusterMembership(router)
|
|
cm.addNode("node1")
|
|
cm.addNode("node2")
|
|
cm.addNode("node3")
|
|
cm.removeNode("node2")
|
|
check cm.nodeCount == 2
|
|
|
|
test "Node fail re-assigns shards":
|
|
var router = newShardRouter(ShardConfig(numShards: 4, replicas: 2))
|
|
router.rebalance(@["node1", "node2", "node3"])
|
|
var cm = newClusterMembership(router)
|
|
cm.nodes = @["node1", "node2", "node3"]
|
|
cm.onNodeFail("node1")
|
|
check cm.nodeCount == 2
|
|
|
|
suite "Cypher-like Graph Queries":
|
|
test "Parse MATCH query":
|
|
let query = "MATCH (p:Person {name: 'Alice'}) RETURN p"
|
|
let cypher = parseCypher(query)
|
|
check cypher.kind == "MATCH"
|
|
check cypher.pattern.nodes.len == 1
|
|
check cypher.pattern.nodes[0].label == "Person"
|
|
check cypher.returnExprs.len == 1
|
|
|
|
test "Parse MATCH with edge":
|
|
let query = "MATCH (a:Person)-[r:KNOWS]->(b:Person) RETURN a, b"
|
|
let cypher = parseCypher(query)
|
|
check cypher.pattern.nodes.len == 2
|
|
check cypher.pattern.edges.len == 1
|
|
check cypher.pattern.edges[0].label == "KNOWS"
|
|
|
|
test "Parse MATCH with WHERE and LIMIT":
|
|
let query = "MATCH (p:Person) WHERE p.age > 18 RETURN p.name, p.age ORDER BY p.age LIMIT 10"
|
|
let cypher = parseCypher(query)
|
|
check cypher.whereClause.len > 0
|
|
check cypher.returnExprs.len == 2
|
|
check cypher.orderBy.len > 0
|
|
check cypher.limit == 10
|
|
|
|
test "Execute basic MATCH":
|
|
var g = newGraph()
|
|
discard g.addNode("Person", {"name": "Alice"}.toTable)
|
|
discard g.addNode("Person", {"name": "Bob"}.toTable)
|
|
discard g.addNode("Company", {"name": "Acme"}.toTable)
|
|
|
|
let query = parseCypher("MATCH (p:Person) RETURN p")
|
|
let result = executeCypher(g, query)
|
|
check result.rows.len == 2
|
|
|
|
test "Match nodes with properties":
|
|
var g = newGraph()
|
|
discard g.addNode("Person", {"name": "Alice", "age": "30"}.toTable)
|
|
discard g.addNode("Person", {"name": "Bob", "age": "25"}.toTable)
|
|
|
|
let matches = matchNodes(g, "Person", {"name": "Alice"}.toTable)
|
|
check matches.len == 1
|
|
check matches[0].properties["name"] == "Alice"
|
|
|
|
suite "Crash Recovery":
|
|
test "Scan WAL file":
|
|
var walDir = "/tmp/baradb_test_recovery_wal"
|
|
var wal = newWriteAheadLog(walDir)
|
|
wal.writePut(@[1'u8], @[2'u8], 1)
|
|
wal.sync()
|
|
wal.close()
|
|
|
|
var rec = newCrashRecovery(walDir, "/tmp")
|
|
let entries = rec.scanWAL()
|
|
check entries.len >= 1 # at least the put entry
|
|
|
|
test "Analyze recovery":
|
|
var walDir = "/tmp/baradb_test_recovery_wal2"
|
|
var wal = newWriteAheadLog(walDir)
|
|
wal.writePut(@[1'u8], @[2'u8], 1)
|
|
wal.writeCommit(1)
|
|
wal.close()
|
|
|
|
var rec = newCrashRecovery(walDir, "/tmp")
|
|
let result = rec.analyze()
|
|
check result.totalEntries >= 1
|
|
check result.applied == true
|
|
|
|
test "Recover returns summary":
|
|
var walDir = "/tmp/baradb_test_recovery_wal3"
|
|
var wal = newWriteAheadLog(walDir)
|
|
wal.writePut(@[1'u8], @[2'u8], 1)
|
|
wal.writeCommit(1)
|
|
wal.close()
|
|
|
|
var rec = newCrashRecovery(walDir, "/tmp")
|
|
let summary = rec.summary()
|
|
check "WAL Recovery" in summary
|
|
check "Total" in summary
|
|
|
|
suite "Raft Election Timer":
|
|
test "Election timer tick":
|
|
var cluster = newRaftCluster()
|
|
cluster.addNode("n1")
|
|
cluster.addNode("n2")
|
|
cluster.addNode("n3")
|
|
|
|
let n1 = cluster.nodes["n1"]
|
|
var timer = newElectionTimer(n1, timeoutMs = 0) # immediate timeout
|
|
|
|
# Force election
|
|
n1.becomeCandidate()
|
|
n1.becomeLeader()
|
|
timer.tick()
|
|
check n1.isLeader
|
|
|
|
test "Timer reset":
|
|
var cluster = newRaftCluster()
|
|
cluster.addNode("n1")
|
|
let n1 = cluster.nodes["n1"]
|
|
var timer = newElectionTimer(n1, timeoutMs = 1000)
|
|
timer.resetTimeout()
|
|
check not timer.checkTimeout()
|
|
|
|
test "Multi-node election with timer":
|
|
var cluster = newRaftCluster()
|
|
cluster.addNode("n1")
|
|
cluster.addNode("n2")
|
|
|
|
let n2 = cluster.nodes["n2"]
|
|
var timer2 = newElectionTimer(n2, timeoutMs = 0)
|
|
n2.becomeCandidate()
|
|
let req = n2.requestVote()
|
|
check req.len == 1 # n2 requests vote from n1
|
|
let reply = cluster.nodes["n1"].handleRequestVote(req[0])
|
|
check reply.success
|
|
|
|
suite "Raft Network Transport":
|
|
test "3-node election over TCP":
|
|
var n1 = newRaftNode("n1", @["n2", "n3"], raftPort = 29001)
|
|
var n2 = newRaftNode("n2", @["n1", "n3"], raftPort = 29002)
|
|
var n3 = newRaftNode("n3", @["n1", "n2"], raftPort = 29003)
|
|
|
|
n1.peerAddrs["n2"] = ("127.0.0.1", 29002)
|
|
n1.peerAddrs["n3"] = ("127.0.0.1", 29003)
|
|
n2.peerAddrs["n1"] = ("127.0.0.1", 29001)
|
|
n2.peerAddrs["n3"] = ("127.0.0.1", 29003)
|
|
n3.peerAddrs["n1"] = ("127.0.0.1", 29001)
|
|
n3.peerAddrs["n2"] = ("127.0.0.1", 29002)
|
|
|
|
let net1 = newRaftNetwork(n1)
|
|
let net2 = newRaftNetwork(n2)
|
|
let net3 = newRaftNetwork(n3)
|
|
|
|
asyncCheck net1.run()
|
|
asyncCheck net2.run()
|
|
asyncCheck net3.run()
|
|
waitFor sleepAsync(50)
|
|
|
|
var timer1 = newElectionTimer(n1, timeoutMs = 50)
|
|
var timer2 = newElectionTimer(n2, timeoutMs = 100)
|
|
var timer3 = newElectionTimer(n3, timeoutMs = 150)
|
|
|
|
for i in 0 ..< 30:
|
|
timer1.tick(net1)
|
|
timer2.tick(net2)
|
|
timer3.tick(net3)
|
|
waitFor sleepAsync(20)
|
|
|
|
net1.stop()
|
|
net2.stop()
|
|
net3.stop()
|
|
waitFor sleepAsync(50)
|
|
|
|
var leaderCount = 0
|
|
if n1.isLeader: inc leaderCount
|
|
if n2.isLeader: inc leaderCount
|
|
if n3.isLeader: inc leaderCount
|
|
check leaderCount == 1
|
|
|
|
suite "CLI Autocomplete":
|
|
test "Autocomplete commands":
|
|
let res = autocomplete("he")
|
|
check "help" in res
|
|
|
|
test "Autocomplete keywords":
|
|
let res = autocomplete("SEL")
|
|
check "SELECT" in res
|
|
let res2 = autocomplete("SELECT FRO")
|
|
check "FROM" in res2
|
|
|
|
test "Suggest returns completions":
|
|
let suggestion = suggest("SE")
|
|
check suggestion.len > 0
|
|
|
|
test "Autocomplete empty":
|
|
let res = autocomplete("")
|
|
check res.len == 0
|
|
|
|
suite "TLS/SSL":
|
|
test "Create TLS config":
|
|
let config = newTLSConfig("cert.pem", "key.pem", "ca.pem", verifyPeer = true)
|
|
check config.certFile == "cert.pem"
|
|
check config.verifyPeer == true
|
|
|
|
test "Validate cert — missing file":
|
|
let errors = validateCert("nonexistent.pem")
|
|
check errors.len > 0
|
|
|
|
test "Certificate info parsing":
|
|
# Write a dummy PEM cert
|
|
let testCert = "/tmp/baradb_test_cert.pem"
|
|
writeFile(testCert, "Subject: CN=localhost\nIssuer: CN=localhost\n")
|
|
let info = parseCertInfo(testCert)
|
|
check info.subject.len > 0
|
|
check info.isSelfSigned # subject == issuer
|
|
|
|
test "TLS context creation with missing cert raises":
|
|
var raised = false
|
|
try:
|
|
discard newTLSContext(newTLSConfig("nonexistent.pem", "nonexistent.key"))
|
|
except IOError:
|
|
raised = true
|
|
check raised
|
|
|
|
test "Generate self-signed cert":
|
|
let (certPath, keyPath) = generateSelfSignedCert("/tmp/baradb_test_tls", "test.local")
|
|
# May fail if openssl not installed
|
|
if certPath.len > 0:
|
|
check fileExists(certPath)
|
|
check fileExists(keyPath)
|
|
# Should be able to create TLS context from generated cert
|
|
let ctx = newTLSContext(newTLSConfig(certPath, keyPath))
|
|
check ctx != nil
|
|
|
|
test "Server with TLS config":
|
|
var cfg = defaultConfig()
|
|
cfg.tlsEnabled = true
|
|
let (certPath, keyPath) = generateSelfSignedCert("/tmp/baradb_test_tls2", "test.local")
|
|
if certPath.len > 0:
|
|
cfg.certFile = certPath
|
|
cfg.keyFile = keyPath
|
|
var srv = newServer(cfg)
|
|
check srv != nil
|
|
check srv.tls != nil
|
|
|
|
suite "Triggers":
|
|
test "Parse CREATE TRIGGER":
|
|
let ast = parse("CREATE TRIGGER log_insert BEFORE INSERT ON users AS INSERT INTO audit_log VALUES ('insert', 'users')")
|
|
check ast.stmts.len == 1
|
|
check ast.stmts[0].kind == nkCreateTrigger
|
|
check ast.stmts[0].trigName == "log_insert"
|
|
check ast.stmts[0].trigTable == "users"
|
|
check ast.stmts[0].trigTiming == "before"
|
|
check ast.stmts[0].trigEvent == "INSERT"
|
|
check ast.stmts[0].trigAction.strVal.contains("INSERT")
|
|
check ast.stmts[0].trigAction.strVal.contains("audit_log")
|
|
|
|
test "Parse CREATE TRIGGER AFTER UPDATE":
|
|
let ast = parse("CREATE TRIGGER audit_update AFTER UPDATE ON orders AS INSERT INTO audit VALUES ('updated')")
|
|
check ast.stmts[0].kind == nkCreateTrigger
|
|
check ast.stmts[0].trigTiming == "after"
|
|
check ast.stmts[0].trigEvent == "UPDATE"
|
|
|
|
test "Parse CREATE TRIGGER INSTEAD OF DELETE":
|
|
let ast = parse("CREATE TRIGGER soft_delete INSTEAD OF DELETE ON users AS UPDATE users SET deleted = true WHERE id = OLD.id")
|
|
check ast.stmts[0].kind == nkCreateTrigger
|
|
check ast.stmts[0].trigTiming == "instead of"
|
|
check ast.stmts[0].trigEvent == "DELETE"
|
|
|
|
test "Parse DROP TRIGGER":
|
|
let ast = parse("DROP TRIGGER log_insert")
|
|
check ast.stmts.len == 1
|
|
check ast.stmts[0].kind == nkDropTrigger
|
|
check ast.stmts[0].trigDropName == "log_insert"
|
|
check ast.stmts[0].trigDropIfExists == false
|
|
|
|
test "Parse DROP TRIGGER IF EXISTS":
|
|
let ast = parse("DROP TRIGGER IF EXISTS old_trigger")
|
|
check ast.stmts[0].kind == nkDropTrigger
|
|
check ast.stmts[0].trigDropName == "old_trigger"
|
|
check ast.stmts[0].trigDropIfExists == true
|
|
|
|
suite "Row-Level Security":
|
|
test "Parse CREATE USER":
|
|
let ast = parse("CREATE USER admin WITH PASSWORD 'secret' SUPERUSER")
|
|
check ast.stmts.len == 1
|
|
check ast.stmts[0].kind == nkCreateUser
|
|
check ast.stmts[0].cuName == "admin"
|
|
check ast.stmts[0].cuPassword == "secret"
|
|
check ast.stmts[0].cuSuperuser == true
|
|
|
|
test "Parse CREATE USER without superuser":
|
|
let ast = parse("CREATE USER reader WITH PASSWORD 'reader123'")
|
|
check ast.stmts[0].kind == nkCreateUser
|
|
check ast.stmts[0].cuName == "reader"
|
|
check ast.stmts[0].cuPassword == "reader123"
|
|
check ast.stmts[0].cuSuperuser == false
|
|
|
|
test "Parse DROP USER":
|
|
let ast = parse("DROP USER admin")
|
|
check ast.stmts[0].kind == nkDropUser
|
|
check ast.stmts[0].duName == "admin"
|
|
|
|
test "Parse CREATE POLICY":
|
|
let ast = parse("CREATE POLICY user_isolation ON accounts FOR SELECT USING (user_id = current_user)")
|
|
check ast.stmts[0].kind == nkCreatePolicy
|
|
check ast.stmts[0].cpName == "user_isolation"
|
|
check ast.stmts[0].cpTable == "accounts"
|
|
check ast.stmts[0].cpCommand == "SELECT"
|
|
|
|
test "Parse CREATE POLICY with WITH CHECK":
|
|
let ast = parse("CREATE POLICY insert_check ON accounts FOR INSERT WITH CHECK (amount > 0)")
|
|
check ast.stmts[0].kind == nkCreatePolicy
|
|
check ast.stmts[0].cpCommand == "INSERT"
|
|
|
|
test "Parse DROP POLICY":
|
|
let ast = parse("DROP POLICY user_isolation ON accounts")
|
|
check ast.stmts[0].kind == nkDropPolicy
|
|
check ast.stmts[0].dpName == "user_isolation"
|
|
check ast.stmts[0].dpTable == "accounts"
|
|
|
|
test "Parse GRANT":
|
|
let ast = parse("GRANT SELECT ON accounts TO reader")
|
|
check ast.stmts[0].kind == nkGrant
|
|
check ast.stmts[0].grPrivilege == "SELECT"
|
|
check ast.stmts[0].grTable == "accounts"
|
|
check ast.stmts[0].grGrantee == "reader"
|
|
|
|
test "Parse REVOKE":
|
|
let ast = parse("REVOKE INSERT ON accounts FROM reader")
|
|
check ast.stmts[0].kind == nkRevoke
|
|
check ast.stmts[0].rvPrivilege == "INSERT"
|
|
check ast.stmts[0].rvTable == "accounts"
|
|
check ast.stmts[0].rvGrantee == "reader"
|
|
|
|
test "Parse ENABLE ROW LEVEL SECURITY":
|
|
let ast = parse("ALTER TABLE accounts ENABLE ROW LEVEL SECURITY")
|
|
check ast.stmts[0].kind == nkEnableRLS
|
|
check ast.stmts[0].erlsTable == "accounts"
|
|
|
|
test "Parse DISABLE ROW LEVEL SECURITY":
|
|
let ast = parse("ALTER TABLE accounts DISABLE ROW LEVEL SECURITY")
|
|
check ast.stmts[0].kind == nkDisableRLS
|
|
check ast.stmts[0].drlsTable == "accounts"
|
|
|
|
test "RLS filter on SELECT":
|
|
var testDir = getTempDir() / "baradb_rls_test_" & $getCurrentProcessId() & "_" & $getMonoTime().ticks
|
|
createDir(testDir)
|
|
var db = newLSMTree(testDir)
|
|
var ctx = qexec.newExecutionContext(db)
|
|
# Create table and insert data
|
|
discard qexec.executeQuery(ctx, parse("CREATE TABLE docs (id INTEGER, owner TEXT)"))
|
|
discard qexec.executeQuery(ctx, parse("INSERT INTO docs (id, owner) VALUES (1, 'alice'), (2, 'bob')"))
|
|
# Create user and policy
|
|
ctx.currentUser = "alice"
|
|
ctx.users["alice"] = qexec.UserDef(name: "alice", passwordHash: "", isSuperuser: false, roles: @[])
|
|
ctx.policies["docs"] = @[
|
|
qexec.PolicyDef(name: "owner_only", tableName: "docs", command: "SELECT",
|
|
usingExpr: Node(kind: nkBinOp, binOp: bkEq,
|
|
binLeft: Node(kind: nkIdent, identName: "owner"),
|
|
binRight: Node(kind: nkStringLit, strVal: "alice")),
|
|
withCheckExpr: nil)
|
|
]
|
|
# Query should only return alice's row
|
|
let res = qexec.executeQuery(ctx, parse("SELECT id, owner FROM docs"))
|
|
check res.success
|
|
check res.rows.len == 1
|
|
check res.rows[0]["owner"] == "alice"
|
|
|
|
test "RLS superuser bypass":
|
|
var testDir = getTempDir() / "baradb_rls_test_" & $getCurrentProcessId() & "_" & $getMonoTime().ticks
|
|
createDir(testDir)
|
|
var db = newLSMTree(testDir)
|
|
var ctx = qexec.newExecutionContext(db)
|
|
discard qexec.executeQuery(ctx, parse("CREATE TABLE docs (id INTEGER, owner TEXT)"))
|
|
discard qexec.executeQuery(ctx, parse("INSERT INTO docs (id, owner) VALUES (1, 'alice')"))
|
|
ctx.currentUser = "admin"
|
|
ctx.users["admin"] = qexec.UserDef(name: "admin", passwordHash: "", isSuperuser: true, roles: @[])
|
|
ctx.policies["docs"] = @[
|
|
qexec.PolicyDef(name: "owner_only", tableName: "docs", command: "SELECT",
|
|
usingExpr: Node(kind: nkBinOp, binOp: bkEq,
|
|
binLeft: Node(kind: nkIdent, identName: "owner"),
|
|
binRight: Node(kind: nkStringLit, strVal: "alice")),
|
|
withCheckExpr: nil)
|
|
]
|
|
let res = qexec.executeQuery(ctx, parse("SELECT id, owner FROM docs"))
|
|
check res.success
|
|
check res.rows.len == 1 # superuser sees all (only 1 row exists)
|
|
|
|
suite "Session Variables and Multi-Tenant":
|
|
test "SET statement parse":
|
|
let ast = parse("SET app.tenant_id = 'company-123'")
|
|
check ast.stmts.len == 1
|
|
check ast.stmts[0].kind == nkSetVar
|
|
check ast.stmts[0].svName == "app.tenant_id"
|
|
check ast.stmts[0].svValue == "company-123"
|
|
|
|
test "SET and current_setting":
|
|
let tmpDir = getTempDir() / "baradb_session_test_" & $getMonoTime().ticks
|
|
var db = newLSMTree(tmpDir)
|
|
var ctx = qexec.newExecutionContext(db)
|
|
discard qexec.executeQuery(ctx, parse("SET app.tenant_id = 'company-123'"))
|
|
let r = qexec.executeQuery(ctx, parse("SELECT current_setting('app.tenant_id') AS tenant"))
|
|
check r.success
|
|
check r.rows.len == 1
|
|
check r.rows[0]["tenant"] == "company-123"
|
|
|
|
test "current_user in SELECT":
|
|
let tmpDir = getTempDir() / "baradb_user_test_" & $getMonoTime().ticks
|
|
var db = newLSMTree(tmpDir)
|
|
var ctx = qexec.newExecutionContext(db)
|
|
ctx.currentUser = "alice"
|
|
let r = qexec.executeQuery(ctx, parse("SELECT current_user AS me"))
|
|
check r.success
|
|
check r.rows.len == 1
|
|
check r.rows[0]["me"] == "alice"
|
|
|
|
test "current_role in SELECT":
|
|
let tmpDir = getTempDir() / "baradb_role_test_" & $getMonoTime().ticks
|
|
var db = newLSMTree(tmpDir)
|
|
var ctx = qexec.newExecutionContext(db)
|
|
ctx.currentRole = "admin"
|
|
let r = qexec.executeQuery(ctx, parse("SELECT current_role AS role"))
|
|
check r.success
|
|
check r.rows.len == 1
|
|
check r.rows[0]["role"] == "admin"
|
|
|
|
test "Multi-tenant RLS with current_setting":
|
|
let tmpDir = getTempDir() / "baradb_multitenant_test_" & $getMonoTime().ticks
|
|
var db = newLSMTree(tmpDir)
|
|
var ctx = qexec.newExecutionContext(db)
|
|
discard qexec.executeQuery(ctx, parse("CREATE TABLE invoices (id INTEGER, tenant_id TEXT, amount INT)"))
|
|
discard qexec.executeQuery(ctx, parse("INSERT INTO invoices (id, tenant_id, amount) VALUES (1, 'company-a', 100)"))
|
|
discard qexec.executeQuery(ctx, parse("INSERT INTO invoices (id, tenant_id, amount) VALUES (2, 'company-b', 200)"))
|
|
# Set session variable for tenant
|
|
discard qexec.executeQuery(ctx, parse("SET app.tenant_id = 'company-a'"))
|
|
# Create policy that uses current_setting
|
|
ctx.users["app"] = qexec.UserDef(name: "app", passwordHash: "", isSuperuser: false, roles: @[])
|
|
ctx.currentUser = "app"
|
|
ctx.policies["invoices"] = @[
|
|
qexec.PolicyDef(name: "tenant_isolation", tableName: "invoices", command: "SELECT",
|
|
usingExpr: Node(kind: nkBinOp, binOp: bkEq,
|
|
binLeft: Node(kind: nkIdent, identName: "tenant_id"),
|
|
binRight: Node(kind: nkFuncCall, funcName: "current_setting",
|
|
funcArgs: @[Node(kind: nkStringLit, strVal: "app.tenant_id")])),
|
|
withCheckExpr: nil)
|
|
]
|
|
let r = qexec.executeQuery(ctx, parse("SELECT id, tenant_id FROM invoices"))
|
|
check r.success
|
|
check r.rows.len == 1
|
|
check r.rows[0]["tenant_id"] == "company-a"
|
|
|
|
suite "UTF-8 Support":
|
|
test "Tokenize UTF-8 identifiers":
|
|
let tokens = lex.tokenize("SELECT имя FROM потребители")
|
|
check tokens[1].kind == tkIdent
|
|
check tokens[1].value == "имя"
|
|
check tokens[3].kind == tkIdent
|
|
check tokens[3].value == "потребители"
|
|
|
|
test "Parse UTF-8 table and column names":
|
|
let ast = parse("SELECT имя, възраст FROM потребители WHERE град = 'София'")
|
|
check ast.stmts[0].kind == nkSelect
|
|
check ast.stmts[0].selFrom.fromTable == "потребители"
|
|
check ast.stmts[0].selResult[0].identName == "имя"
|
|
check ast.stmts[0].selWhere.whereExpr.binRight.strVal == "София"
|
|
|
|
test "Execute query with UTF-8 data":
|
|
let tmpDir = getTempDir() / "baradb_test_" & $getMonoTime().ticks
|
|
var db = newLSMTree(tmpDir)
|
|
var ctx = qexec.newExecutionContext(db)
|
|
discard qexec.executeQuery(ctx, parse("CREATE TABLE потребители (имя TEXT, град TEXT)"))
|
|
discard qexec.executeQuery(ctx, parse("INSERT INTO потребители (имя, град) VALUES ('Иван', 'София'), ('Мария', 'Пловдив')"))
|
|
let res = qexec.executeQuery(ctx, parse("SELECT имя, град FROM потребители WHERE град = 'София'"))
|
|
check res.success
|
|
check res.rows.len == 1
|
|
check res.rows[0]["имя"] == "Иван"
|
|
check res.rows[0]["град"] == "София"
|
|
|
|
suite "B-Tree Range Scan":
|
|
test "BETWEEN uses index range scan":
|
|
let tmpDir = getTempDir() / "baradb_test_" & $getMonoTime().ticks
|
|
var db = newLSMTree(tmpDir)
|
|
var ctx = qexec.newExecutionContext(db)
|
|
discard qexec.executeQuery(ctx, parse("CREATE TABLE products (id INTEGER, name TEXT)"))
|
|
discard qexec.executeQuery(ctx, parse("INSERT INTO products (id, name) VALUES (1, 'apple'), (2, 'banana'), (3, 'cherry'), (4, 'date'), (5, 'elderberry')"))
|
|
discard qexec.executeQuery(ctx, parse("CREATE INDEX idx_products_name ON products(name)"))
|
|
let res = qexec.executeQuery(ctx, parse("SELECT name FROM products WHERE name BETWEEN 'banana' AND 'date'"))
|
|
check res.success
|
|
check res.rows.len == 3
|
|
|
|
test "Greater than uses index range scan":
|
|
let tmpDir = getTempDir() / "baradb_test_" & $getMonoTime().ticks
|
|
var db = newLSMTree(tmpDir)
|
|
var ctx = qexec.newExecutionContext(db)
|
|
discard qexec.executeQuery(ctx, parse("CREATE TABLE nums (id INTEGER, val TEXT)"))
|
|
discard qexec.executeQuery(ctx, parse("INSERT INTO nums (id, val) VALUES (1, '10'), (2, '20'), (3, '30'), (4, '40'), (5, '50')"))
|
|
discard qexec.executeQuery(ctx, parse("CREATE INDEX idx_nums_val ON nums(val)"))
|
|
let res = qexec.executeQuery(ctx, parse("SELECT val FROM nums WHERE val > '20'"))
|
|
check res.success
|
|
check res.rows.len == 3
|
|
|
|
test "Less than or equal uses index range scan":
|
|
let tmpDir = getTempDir() / "baradb_test_" & $getMonoTime().ticks
|
|
var db = newLSMTree(tmpDir)
|
|
var ctx = qexec.newExecutionContext(db)
|
|
discard qexec.executeQuery(ctx, parse("CREATE TABLE nums2 (id INTEGER, val TEXT)"))
|
|
discard qexec.executeQuery(ctx, parse("INSERT INTO nums2 (id, val) VALUES (1, '10'), (2, '20'), (3, '30'), (4, '40'), (5, '50')"))
|
|
discard qexec.executeQuery(ctx, parse("CREATE INDEX idx_nums2_val ON nums2(val)"))
|
|
let res = qexec.executeQuery(ctx, parse("SELECT val FROM nums2 WHERE val <= '30'"))
|
|
check res.success
|
|
check res.rows.len == 3
|
|
|
|
suite "Enhanced Migrations":
|
|
test "Parse CREATE MIGRATION with UP/DOWN":
|
|
let ast = parse("CREATE MIGRATION add_users { UP: CREATE TABLE users (id INTEGER PRIMARY KEY); DOWN: DROP TABLE users; }")
|
|
check ast.stmts.len == 1
|
|
check ast.stmts[0].kind == nkCreateMigration
|
|
check ast.stmts[0].cmName == "add_users"
|
|
check ast.stmts[0].cmBody.contains("CREATE TABLE users")
|
|
check ast.stmts[0].cmDownBody.contains("DROP TABLE users")
|
|
|
|
test "Parse MIGRATION STATUS":
|
|
let ast = parse("MIGRATION STATUS")
|
|
check ast.stmts[0].kind == nkMigrationStatus
|
|
|
|
test "Parse MIGRATION UP":
|
|
let ast = parse("MIGRATION UP")
|
|
check ast.stmts[0].kind == nkMigrationUp
|
|
check ast.stmts[0].muCount == 0
|
|
|
|
test "Parse MIGRATION UP 5":
|
|
let ast = parse("MIGRATION UP 5")
|
|
check ast.stmts[0].kind == nkMigrationUp
|
|
check ast.stmts[0].muCount == 5
|
|
|
|
test "Parse MIGRATION DOWN":
|
|
let ast = parse("MIGRATION DOWN")
|
|
check ast.stmts[0].kind == nkMigrationDown
|
|
check ast.stmts[0].mdCount == 1
|
|
|
|
test "Parse MIGRATION DOWN 3":
|
|
let ast = parse("MIGRATION DOWN 3")
|
|
check ast.stmts[0].kind == nkMigrationDown
|
|
check ast.stmts[0].mdCount == 3
|
|
|
|
test "Parse MIGRATION DRYRUN":
|
|
let ast = parse("MIGRATION DRYRUN add_users")
|
|
check ast.stmts[0].kind == nkMigrationDryRun
|
|
check ast.stmts[0].mdrName == "add_users"
|
|
|
|
test "Create and apply migration with checksum":
|
|
var testDir = getTempDir() / "baradb_migration_test_" & $getCurrentProcessId() & "_" & $getMonoTime().ticks
|
|
createDir(testDir)
|
|
var db = newLSMTree(testDir)
|
|
var ctx = qexec.newExecutionContext(db)
|
|
# Create migration
|
|
let createRes = qexec.executeQuery(ctx, parse("CREATE MIGRATION add_users { UP: CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT); DOWN: DROP TABLE users; }"))
|
|
check createRes.success
|
|
check createRes.message.contains("checksum")
|
|
# Apply migration
|
|
let applyRes = qexec.executeQuery(ctx, parse("APPLY MIGRATION add_users"))
|
|
check applyRes.success
|
|
check applyRes.message.contains("ms")
|
|
# Check table exists
|
|
let tableRes = qexec.executeQuery(ctx, parse("SELECT name FROM users"))
|
|
check tableRes.success # table exists (empty result is OK)
|
|
# Re-apply should be idempotent
|
|
let reapplyRes = qexec.executeQuery(ctx, parse("APPLY MIGRATION add_users"))
|
|
check reapplyRes.success
|
|
check reapplyRes.message.contains("already applied")
|
|
|
|
test "Migration STATUS shows applied migrations":
|
|
var testDir = getTempDir() / "baradb_migration_test_" & $getCurrentProcessId() & "_" & $getMonoTime().ticks
|
|
createDir(testDir)
|
|
var db = newLSMTree(testDir)
|
|
var ctx = qexec.newExecutionContext(db)
|
|
discard qexec.executeQuery(ctx, parse("CREATE MIGRATION m1 { UP: CREATE TABLE t1 (id INTEGER); }"))
|
|
discard qexec.executeQuery(ctx, parse("CREATE MIGRATION m2 { UP: CREATE TABLE t2 (id INTEGER); }"))
|
|
discard qexec.executeQuery(ctx, parse("APPLY MIGRATION m1"))
|
|
let statusRes = qexec.executeQuery(ctx, parse("MIGRATION STATUS"))
|
|
check statusRes.success
|
|
check statusRes.rows.len == 2
|
|
check statusRes.rows[0]["status"] == "applied"
|
|
check statusRes.rows[1]["status"] == "pending"
|
|
|
|
test "Migration UP applies all pending":
|
|
var testDir = getTempDir() / "baradb_migration_test_" & $getCurrentProcessId() & "_" & $getMonoTime().ticks
|
|
createDir(testDir)
|
|
var db = newLSMTree(testDir)
|
|
var ctx = qexec.newExecutionContext(db)
|
|
discard qexec.executeQuery(ctx, parse("CREATE MIGRATION m1 { UP: CREATE TABLE t1 (id INTEGER); }"))
|
|
discard qexec.executeQuery(ctx, parse("CREATE MIGRATION m2 { UP: CREATE TABLE t2 (id INTEGER); }"))
|
|
let upRes = qexec.executeQuery(ctx, parse("MIGRATION UP"))
|
|
check upRes.success
|
|
check upRes.message.contains("Applied 2 migrations")
|
|
|
|
test "Migration DOWN rollback":
|
|
var testDir = getTempDir() / "baradb_migration_test_" & $getCurrentProcessId() & "_" & $getMonoTime().ticks
|
|
createDir(testDir)
|
|
var db = newLSMTree(testDir)
|
|
var ctx = qexec.newExecutionContext(db)
|
|
discard qexec.executeQuery(ctx, parse("CREATE MIGRATION add_t { UP: CREATE TABLE t (id INTEGER); DOWN: DROP TABLE t; }"))
|
|
discard qexec.executeQuery(ctx, parse("APPLY MIGRATION add_t"))
|
|
let downRes = qexec.executeQuery(ctx, parse("MIGRATION DOWN"))
|
|
check downRes.success
|
|
check downRes.message.contains("Rolled back 1 migrations")
|
|
# After rollback, table should be gone (check by listing tables)
|
|
let tableRes = qexec.executeQuery(ctx, parse("SELECT name FROM __tables WHERE name = 't'"))
|
|
check tableRes.success
|
|
check tableRes.rows.len == 0 # table does not exist
|
|
|
|
test "Migration DRYRUN":
|
|
var testDir = getTempDir() / "baradb_migration_test_" & $getCurrentProcessId() & "_" & $getMonoTime().ticks
|
|
createDir(testDir)
|
|
var db = newLSMTree(testDir)
|
|
var ctx = qexec.newExecutionContext(db)
|
|
discard qexec.executeQuery(ctx, parse("CREATE MIGRATION add_t { UP: CREATE TABLE t (id INTEGER); CREATE INDEX idx ON t(id); DOWN: DROP TABLE t; }"))
|
|
let dryRes = qexec.executeQuery(ctx, parse("MIGRATION DRYRUN add_t"))
|
|
check dryRes.success
|
|
check dryRes.message.contains("DRY RUN")
|
|
check dryRes.message.contains("Statements: 2")
|
|
check dryRes.message.contains("DOWN script: yes")
|
|
|
|
suite "Parameterized queries":
|
|
var db: LSMTree
|
|
var ctx: qexec.ExecutionContext
|
|
var tmpDir: string
|
|
var paramInitialized = false
|
|
|
|
setup:
|
|
if not paramInitialized:
|
|
tmpDir = getTempDir() / "baradb_param_test_" & $getMonoTime().ticks
|
|
db = newLSMTree(tmpDir)
|
|
ctx = qexec.newExecutionContext(db)
|
|
discard qexec.executeQuery(ctx, parse("CREATE TABLE users (id INT, name TEXT, age INT, active BOOLEAN)"))
|
|
discard qexec.executeQuery(ctx, parse("INSERT INTO users (id, name, age, active) VALUES (1, 'Alice', 30, true)"))
|
|
discard qexec.executeQuery(ctx, parse("INSERT INTO users (id, name, age, active) VALUES (2, 'Bob', 25, false)"))
|
|
paramInitialized = true
|
|
|
|
test "SELECT with placeholder params":
|
|
let sql = "SELECT * FROM users WHERE id = ?"
|
|
let tokens = lex.tokenize(sql)
|
|
let ast = parse(tokens)
|
|
let params = @[WireValue(kind: fkInt64, int64Val: 1)]
|
|
let r = qexec.executeQuery(ctx, ast, params)
|
|
check r.success
|
|
check r.rows.len == 1
|
|
check r.rows[0]["name"] == "Alice"
|
|
|
|
test "INSERT with placeholder params":
|
|
let sql = "INSERT INTO users (id, name, age) VALUES (?, ?, ?)"
|
|
let tokens = lex.tokenize(sql)
|
|
let ast = parse(tokens)
|
|
let params = @[
|
|
WireValue(kind: fkInt64, int64Val: 3),
|
|
WireValue(kind: fkString, strVal: "Charlie"),
|
|
WireValue(kind: fkInt64, int64Val: 35)
|
|
]
|
|
let r = qexec.executeQuery(ctx, ast, params)
|
|
check r.success
|
|
let selectR = qexec.executeQuery(ctx, parse("SELECT * FROM users WHERE id = 3"))
|
|
check selectR.rows.len == 1
|
|
check selectR.rows[0]["name"] == "Charlie"
|
|
|
|
test "SELECT with multiple placeholders":
|
|
let sql = "SELECT * FROM users WHERE age > ? AND name = ?"
|
|
let tokens = lex.tokenize(sql)
|
|
let ast = parse(tokens)
|
|
let params = @[WireValue(kind: fkInt64, int64Val: 25), WireValue(kind: fkString, strVal: "Alice")]
|
|
let r = qexec.executeQuery(ctx, ast, params)
|
|
check r.success
|
|
check r.rows.len == 1
|
|
check r.rows[0]["name"] == "Alice"
|
|
|
|
test "JSON type validation":
|
|
let createTbl = parse("CREATE TABLE json_test (id INT PRIMARY KEY, data JSON)")
|
|
discard qexec.executeQuery(ctx, createTbl)
|
|
let valid = parse("INSERT INTO json_test (id, data) VALUES (1, '{\"key\": \"value\"}')")
|
|
let r1 = qexec.executeQuery(ctx, valid)
|
|
check r1.success
|
|
let invalid = parse("INSERT INTO json_test (id, data) VALUES (2, 'not json')")
|
|
let r2 = qexec.executeQuery(ctx, invalid)
|
|
check not r2.success
|
|
check r2.message.contains("JSON")
|
|
|
|
test "Multi-column index parse and create":
|
|
let ast = parse("CREATE INDEX idx_mc ON users (name, age)")
|
|
check ast.stmts[0].kind == nkCreateIndex
|
|
check ast.stmts[0].ciColumns.len == 2
|
|
check ast.stmts[0].ciColumns[0] == "name"
|
|
check ast.stmts[0].ciColumns[1] == "age"
|
|
let r = qexec.executeQuery(ctx, ast)
|
|
check r.success
|
|
check r.message.contains("CREATE INDEX")
|
|
|
|
test "CTE non-recursive execution":
|
|
let ast = parse("WITH active AS (SELECT * FROM users WHERE active = true) SELECT * FROM active")
|
|
let r = qexec.executeQuery(ctx, ast)
|
|
check r.success
|
|
check r.rows.len >= 1
|
|
|
|
test "CTE recursive parse":
|
|
let ast = parse("WITH RECURSIVE nums AS (SELECT 1 AS n) SELECT * FROM nums")
|
|
check ast.stmts[0].selWith.len == 1
|
|
check ast.stmts[0].selWith[0][0] == "nums"
|
|
check ast.stmts[0].selWith[0][2] == true
|
|
|
|
test "UNION ALL parse":
|
|
let ast = parse("SELECT 1 AS n UNION ALL SELECT 2 AS n")
|
|
check ast.stmts[0].kind == nkSetOp
|
|
check ast.stmts[0].setOpKind == sdkUnion
|
|
check ast.stmts[0].setOpAll == true
|
|
check ast.stmts[0].setOpLeft.kind == nkSelect
|
|
check ast.stmts[0].setOpRight.kind == nkSelect
|
|
|
|
test "UNION ALL execution":
|
|
discard qexec.executeQuery(ctx, parse("INSERT INTO users (name, age, active) VALUES ('union_a', '30', 'true')"))
|
|
discard qexec.executeQuery(ctx, parse("INSERT INTO users (name, age, active) VALUES ('union_b', '25', 'false')"))
|
|
let ast = parse("SELECT name FROM users WHERE name = 'union_a' UNION ALL SELECT name FROM users WHERE name = 'union_b'")
|
|
let r = qexec.executeQuery(ctx, ast)
|
|
check r.success
|
|
check r.rows.len == 2
|
|
|
|
test "Simple recursive CTE execution":
|
|
let ast = parse("WITH RECURSIVE nums AS (SELECT 0 AS n FROM users LIMIT 1 UNION ALL SELECT n + 1 FROM nums WHERE n < 2) SELECT n FROM nums ORDER BY n ASC")
|
|
let r = qexec.executeQuery(ctx, ast)
|
|
check r.success
|
|
|
|
test "DROP INDEX parse":
|
|
let ast = parse("DROP INDEX myidx")
|
|
check ast.stmts[0].kind == nkDropIndex
|
|
check ast.stmts[0].diName == "myidx"
|
|
|
|
test "DROP INDEX execution":
|
|
let tbl = ctx.tables["users"]
|
|
let colKey = "users.name"
|
|
ctx.btrees[colKey] = newBTreeIndex[string, IndexEntry]()
|
|
let dropAst = parse("DROP INDEX users.name")
|
|
let r = qexec.executeQuery(ctx, dropAst)
|
|
check r.success
|
|
|
|
test "JSON path operators parse":
|
|
let ast = parse("SELECT data->'name' FROM users")
|
|
check ast.stmts[0].kind == nkSelect
|
|
|
|
test "JSON path operator ->> parse":
|
|
let ast = parse("SELECT data->>'name' FROM users")
|
|
check ast.stmts[0].kind == nkSelect
|
|
|
|
test "JSON path execution":
|
|
discard qexec.executeQuery(ctx, parse("CREATE TABLE IF NOT EXISTS jsontest (id INT PRIMARY KEY, data JSON)"))
|
|
discard qexec.executeQuery(ctx, parse("INSERT INTO jsontest (id, data) VALUES (1, '{\"name\": \"Alice\", \"age\": 30}')"))
|
|
let r = qexec.executeQuery(ctx, parse("SELECT data->'name' AS json_name, data->>'name' AS text_name FROM jsontest"))
|
|
check r.success
|
|
check r.rows.len >= 1
|
|
|
|
test "JSON contains @>":
|
|
discard qexec.executeQuery(ctx, parse("CREATE TABLE IF NOT EXISTS jsontest2 (id INT PRIMARY KEY, data JSON)"))
|
|
discard qexec.executeQuery(ctx, parse("INSERT INTO jsontest2 (id, data) VALUES (1, '{\"name\": \"Alice\", \"age\": 30}')"))
|
|
let r = qexec.executeQuery(ctx, parse("SELECT id FROM jsontest2 WHERE data @> '{\"name\": \"Alice\"}'"))
|
|
check r.success
|
|
check r.rows.len == 1
|
|
check r.rows[0]["id"] == "1"
|
|
|
|
test "JSON contained by <@":
|
|
let r = qexec.executeQuery(ctx, parse("SELECT id FROM jsontest2 WHERE '{\"name\": \"Alice\"}' <@ data"))
|
|
check r.success
|
|
check r.rows.len == 1
|
|
|
|
test "JSON has key json_has_key":
|
|
let r = qexec.executeQuery(ctx, parse("SELECT id FROM jsontest2 WHERE json_has_key(data, 'name') = 'true'"))
|
|
check r.success
|
|
check r.rows.len == 1
|
|
|
|
test "JSON has any key ?|":
|
|
let r = qexec.executeQuery(ctx, parse("SELECT id FROM jsontest2 WHERE data ?| '[\"name\", \"missing\"]'"))
|
|
check r.success
|
|
check r.rows.len == 1
|
|
|
|
test "JSON has all keys ?&":
|
|
let r = qexec.executeQuery(ctx, parse("SELECT id FROM jsontest2 WHERE data ?& '[\"name\", \"age\"]'"))
|
|
check r.success
|
|
check r.rows.len == 1
|
|
|
|
test "FTS match operator @@ parse":
|
|
let ast = parse("SELECT * FROM docs WHERE content @@ 'hello'")
|
|
check ast.stmts[0].kind == nkSelect
|
|
|
|
test "FTS match operator @@ execution":
|
|
discard qexec.executeQuery(ctx, parse("INSERT INTO users (name, age, active) VALUES ('full text search', '30', 'true')"))
|
|
let r = qexec.executeQuery(ctx, parse("SELECT name FROM users WHERE name @@ 'text'"))
|
|
check r.success
|
|
# Should find the row because 'text' is in 'full text search'
|
|
|
|
test "RECOVER TO TIMESTAMP parse":
|
|
let ast = parse("RECOVER TO TIMESTAMP '2026-05-07T12:00:00'")
|
|
check ast.stmts[0].kind == nkRecoverToTimestamp
|
|
|
|
test "RECOVER FROM WAL execution":
|
|
let r = qexec.executeQuery(ctx, parse("RECOVER TO TIMESTAMP '2026-12-31T23:59:59'"))
|
|
check r.success
|
|
|
|
test "FTS index creation USING FTS":
|
|
discard qexec.executeQuery(ctx, parse("CREATE TABLE IF NOT EXISTS fts_test (id INT PRIMARY KEY, body TEXT)"))
|
|
discard qexec.executeQuery(ctx, parse("INSERT INTO fts_test (id, body) VALUES (1, 'the quick brown fox jumps')"))
|
|
discard qexec.executeQuery(ctx, parse("INSERT INTO fts_test (id, body) VALUES (2, 'lazy dog sleeps all day')"))
|
|
discard qexec.executeQuery(ctx, parse("INSERT INTO fts_test (id, body) VALUES (3, 'quick brown dog plays fetch')"))
|
|
let r = qexec.executeQuery(ctx, parse("CREATE INDEX idx_fts_body ON fts_test(body) USING FTS"))
|
|
check r.success
|
|
check r.message.contains("USING FTS")
|
|
|
|
test "FTS index @@ uses BM25":
|
|
let r = qexec.executeQuery(ctx, parse("SELECT id FROM fts_test WHERE body @@ 'quick brown'"))
|
|
check r.success
|
|
check r.rows.len >= 2
|
|
|
|
test "Unary minus in SELECT expression":
|
|
let r = qexec.executeQuery(ctx, parse("SELECT -age AS neg_age FROM users WHERE id = 1"))
|
|
check r.success
|
|
check r.rows.len == 1
|
|
check r.rows[0]["neg_age"] == "-30"
|
|
|
|
test "Unary minus in WHERE condition":
|
|
let r = qexec.executeQuery(ctx, parse("SELECT name FROM users WHERE id = 2 AND -age = -25"))
|
|
check r.success
|
|
check r.rows.len == 1
|
|
check r.rows[0]["name"] == "Bob"
|
|
|
|
suite "Window Functions":
|
|
var db: LSMTree
|
|
var ctx: qexec.ExecutionContext
|
|
var tmpDir: string
|
|
|
|
setup:
|
|
tmpDir = getTempDir() / "baradb_window_test_" & $getMonoTime().ticks
|
|
db = newLSMTree(tmpDir)
|
|
ctx = qexec.newExecutionContext(db)
|
|
discard qexec.executeQuery(ctx, parse("CREATE TABLE employees (id INT, name TEXT, department TEXT, salary INT)"))
|
|
discard qexec.executeQuery(ctx, parse("INSERT INTO employees (id, name, department, salary) VALUES (1, 'Alice', 'Engineering', 90000)"))
|
|
discard qexec.executeQuery(ctx, parse("INSERT INTO employees (id, name, department, salary) VALUES (2, 'Bob', 'Engineering', 80000)"))
|
|
discard qexec.executeQuery(ctx, parse("INSERT INTO employees (id, name, department, salary) VALUES (3, 'Charlie', 'Sales', 70000)"))
|
|
discard qexec.executeQuery(ctx, parse("INSERT INTO employees (id, name, department, salary) VALUES (4, 'Diana', 'Sales', 75000)"))
|
|
discard qexec.executeQuery(ctx, parse("INSERT INTO employees (id, name, department, salary) VALUES (5, 'Eve', 'Engineering', 95000)"))
|
|
|
|
teardown:
|
|
removeDir(tmpDir)
|
|
|
|
test "ROW_NUMBER with PARTITION BY and ORDER BY":
|
|
let r = qexec.executeQuery(ctx, parse("SELECT name, department, ROW_NUMBER() OVER (PARTITION BY department ORDER BY salary DESC) AS rn FROM employees"))
|
|
check r.success
|
|
check r.rows.len == 5
|
|
# Engineering: Eve(95000)=1, Alice(90000)=2, Bob(80000)=3
|
|
# Sales: Diana(75000)=1, Charlie(70000)=2
|
|
var found = initTable[string, string]()
|
|
for row in r.rows:
|
|
found[row["name"]] = row["rn"]
|
|
check found["Eve"] == "1"
|
|
check found["Alice"] == "2"
|
|
check found["Bob"] == "3"
|
|
check found["Diana"] == "1"
|
|
check found["Charlie"] == "2"
|
|
|
|
test "RANK and DENSE_RANK":
|
|
let r = qexec.executeQuery(ctx, parse("SELECT name, salary, RANK() OVER (ORDER BY salary DESC) AS r, DENSE_RANK() OVER (ORDER BY salary DESC) AS dr FROM employees"))
|
|
check r.success
|
|
check r.rows.len == 5
|
|
for row in r.rows:
|
|
if row["name"] == "Eve":
|
|
check row["r"] == "1"
|
|
check row["dr"] == "1"
|
|
if row["name"] == "Alice":
|
|
check row["r"] == "2"
|
|
check row["dr"] == "2"
|
|
|
|
test "LEAD and LAG":
|
|
let r = qexec.executeQuery(ctx, parse("SELECT name, salary, LAG(salary, 1, 0) OVER (ORDER BY salary) AS prev, LEAD(salary, 1, 0) OVER (ORDER BY salary) AS next FROM employees"))
|
|
check r.success
|
|
check r.rows.len == 5
|
|
for row in r.rows:
|
|
if row["name"] == "Charlie":
|
|
check row["prev"] == "0"
|
|
check row["next"] == "75000"
|
|
if row["name"] == "Diana":
|
|
check row["prev"] == "70000"
|
|
check row["next"] == "80000"
|
|
|
|
test "FIRST_VALUE and LAST_VALUE":
|
|
let r = qexec.executeQuery(ctx, parse("SELECT department, FIRST_VALUE(name) OVER (PARTITION BY department ORDER BY salary) AS first, LAST_VALUE(name) OVER (PARTITION BY department ORDER BY salary) AS last FROM employees"))
|
|
check r.success
|
|
check r.rows.len == 5
|
|
|
|
test "NTILE":
|
|
let r = qexec.executeQuery(ctx, parse("SELECT name, NTILE(2) OVER (ORDER BY salary) AS bucket FROM employees"))
|
|
check r.success
|
|
check r.rows.len == 5
|
|
for row in r.rows:
|
|
check row["bucket"] in @["1", "2"]
|
|
|
|
test "FIRST_VALUE with frame":
|
|
let r = qexec.executeQuery(ctx, parse("SELECT name, salary, FIRST_VALUE(salary) OVER (ORDER BY salary ROWS BETWEEN 1 PRECEDING AND CURRENT ROW) AS first_sal FROM employees"))
|
|
check r.success
|
|
check r.rows.len == 5
|
|
for row in r.rows:
|
|
if row["name"] == "Charlie":
|
|
check row["first_sal"] == "70000"
|
|
if row["name"] == "Diana":
|
|
check row["first_sal"] == "70000"
|
|
if row["name"] == "Bob":
|
|
check row["first_sal"] == "75000"
|
|
|
|
test "LAST_VALUE with frame":
|
|
let r = qexec.executeQuery(ctx, parse("SELECT name, salary, LAST_VALUE(salary) OVER (ORDER BY salary ROWS BETWEEN CURRENT ROW AND 1 FOLLOWING) AS last_sal FROM employees"))
|
|
check r.success
|
|
check r.rows.len == 5
|
|
for row in r.rows:
|
|
if row["name"] == "Charlie":
|
|
check row["last_sal"] == "75000"
|
|
if row["name"] == "Diana":
|
|
check row["last_sal"] == "80000"
|
|
if row["name"] == "Bob":
|
|
check row["last_sal"] == "90000"
|
|
|
|
suite "GROUP BY Aggregates":
|
|
var db: LSMTree
|
|
var ctx: qexec.ExecutionContext
|
|
var tmpDir: string
|
|
|
|
setup:
|
|
tmpDir = getTempDir() / "baradb_group_test_" & $getMonoTime().ticks
|
|
db = newLSMTree(tmpDir)
|
|
ctx = qexec.newExecutionContext(db)
|
|
discard qexec.executeQuery(ctx, parse("CREATE TABLE sales (id INT, dept TEXT, amount INT)"))
|
|
discard qexec.executeQuery(ctx, parse("INSERT INTO sales (id, dept, amount) VALUES (1, 'A', 100)"))
|
|
discard qexec.executeQuery(ctx, parse("INSERT INTO sales (id, dept, amount) VALUES (2, 'A', 200)"))
|
|
discard qexec.executeQuery(ctx, parse("INSERT INTO sales (id, dept, amount) VALUES (3, 'B', 150)"))
|
|
discard qexec.executeQuery(ctx, parse("INSERT INTO sales (id, dept, amount) VALUES (4, 'B', 50)"))
|
|
discard qexec.executeQuery(ctx, parse("INSERT INTO sales (id, dept, amount) VALUES (5, 'C', 300)"))
|
|
|
|
teardown:
|
|
removeDir(tmpDir)
|
|
|
|
test "GROUP BY with COUNT(*)":
|
|
let r = qexec.executeQuery(ctx, parse("SELECT dept, COUNT(*) AS cnt FROM sales GROUP BY dept"))
|
|
check r.success
|
|
check r.rows.len == 3
|
|
|
|
test "GROUP BY with SUM":
|
|
let r = qexec.executeQuery(ctx, parse("SELECT dept, SUM(amount) AS total FROM sales GROUP BY dept"))
|
|
check r.success
|
|
check r.rows.len == 3
|
|
var foundA = false
|
|
for row in r.rows:
|
|
if row["dept"] == "A":
|
|
check row["total"] == "300.0"
|
|
foundA = true
|
|
check foundA
|
|
|
|
test "GROUP BY with AVG":
|
|
let r = qexec.executeQuery(ctx, parse("SELECT dept, AVG(amount) AS avg_amt FROM sales GROUP BY dept"))
|
|
check r.success
|
|
check r.rows.len == 3
|
|
var foundB = false
|
|
for row in r.rows:
|
|
if row["dept"] == "B":
|
|
check row["avg_amt"] == "100.0"
|
|
foundB = true
|
|
check foundB
|
|
|
|
test "GROUP BY with MIN and MAX":
|
|
let r = qexec.executeQuery(ctx, parse("SELECT dept, MIN(amount) AS lo, MAX(amount) AS hi FROM sales GROUP BY dept"))
|
|
check r.success
|
|
check r.rows.len == 3
|
|
var foundA = false
|
|
for row in r.rows:
|
|
if row["dept"] == "A":
|
|
check row["lo"] == "100"
|
|
check row["hi"] == "200"
|
|
foundA = true
|
|
check foundA
|
|
|
|
test "GROUP BY with HAVING":
|
|
let r = qexec.executeQuery(ctx, parse("SELECT dept, SUM(amount) AS total FROM sales GROUP BY dept HAVING SUM(amount) > 200"))
|
|
check r.success
|
|
check r.rows.len == 2 # A (300) and C (300)
|
|
for row in r.rows:
|
|
check row["dept"] in @["A", "C"]
|
|
|
|
test "GROUP BY with multiple aggregates":
|
|
let r = qexec.executeQuery(ctx, parse("SELECT dept, COUNT(*) AS cnt, SUM(amount) AS total, AVG(amount) AS avg_amt FROM sales GROUP BY dept"))
|
|
check r.success
|
|
check r.rows.len == 3
|
|
|
|
test "COUNT with FILTER (WHERE ...)":
|
|
let r = qexec.executeQuery(ctx, parse("SELECT COUNT(*) AS total, COUNT(*) FILTER (WHERE amount > 100) AS big FROM sales"))
|
|
check r.success
|
|
check r.rows.len == 1
|
|
check r.rows[0]["total"] == "5"
|
|
check r.rows[0]["big"] == "3"
|
|
|
|
test "SUM with FILTER (WHERE ...)":
|
|
let r = qexec.executeQuery(ctx, parse("SELECT dept, SUM(amount) FILTER (WHERE amount > 100) AS big_total FROM sales GROUP BY dept"))
|
|
check r.success
|
|
check r.rows.len == 3
|
|
var foundA = false
|
|
for row in r.rows:
|
|
if row["dept"] == "A":
|
|
check row["big_total"] == "200.0"
|
|
foundA = true
|
|
check foundA
|
|
|
|
test "ARRAY_AGG":
|
|
let r = qexec.executeQuery(ctx, parse("SELECT dept, ARRAY_AGG(amount) AS amounts FROM sales GROUP BY dept"))
|
|
check r.success
|
|
check r.rows.len == 3
|
|
var foundA = false
|
|
for row in r.rows:
|
|
if row["dept"] == "A":
|
|
check "100" in row["amounts"]
|
|
check "200" in row["amounts"]
|
|
foundA = true
|
|
check foundA
|
|
|
|
test "STRING_AGG":
|
|
let r = qexec.executeQuery(ctx, parse("SELECT dept, STRING_AGG(amount, ',') AS vals FROM sales GROUP BY dept"))
|
|
check r.success
|
|
check r.rows.len == 3
|
|
|
|
test "ROLLUP":
|
|
let r = qexec.executeQuery(ctx, parse("SELECT dept, SUM(amount) AS total FROM sales GROUP BY ROLLUP (dept)"))
|
|
check r.success
|
|
# 3 dept groups + 1 grand total = 4 rows
|
|
check r.rows.len == 4
|
|
|
|
test "PIVOT":
|
|
discard qexec.executeQuery(ctx, parse("CREATE TABLE emp (name TEXT, dept TEXT, salary INT)"))
|
|
discard qexec.executeQuery(ctx, parse("INSERT INTO emp (name, dept, salary) VALUES ('Alice', 'Eng', 90000)"))
|
|
discard qexec.executeQuery(ctx, parse("INSERT INTO emp (name, dept, salary) VALUES ('Bob', 'Eng', 80000)"))
|
|
discard qexec.executeQuery(ctx, parse("INSERT INTO emp (name, dept, salary) VALUES ('Charlie', 'Sales', 70000)"))
|
|
discard qexec.executeQuery(ctx, parse("INSERT INTO emp (name, dept, salary) VALUES ('Diana', 'Sales', 75000)"))
|
|
let r = qexec.executeQuery(ctx, parse("SELECT * FROM (SELECT name, dept, salary FROM emp) PIVOT (SUM(salary) FOR dept IN ('Eng', 'Sales'))"))
|
|
check r.success
|
|
check r.rows.len == 4 # one row per employee
|
|
|
|
test "CUBE execution":
|
|
let r = qexec.executeQuery(ctx, parse("SELECT dept, SUM(amount) AS total FROM sales GROUP BY CUBE (dept)"))
|
|
check r.success
|
|
# 3 dept groups + 1 grand total = 4 rows
|
|
check r.rows.len == 4
|
|
|
|
test "GROUPING SETS execution":
|
|
let r = qexec.executeQuery(ctx, parse("SELECT dept, SUM(amount) AS total FROM sales GROUP BY GROUPING SETS ((dept), ())"))
|
|
check r.success
|
|
# 3 dept groups + 1 grand total = 4 rows
|
|
check r.rows.len == 4
|
|
|
|
test "UNPIVOT execution":
|
|
discard qexec.executeQuery(ctx, parse("CREATE TABLE pivoted (name TEXT, eng_salary INT, sales_salary INT)"))
|
|
discard qexec.executeQuery(ctx, parse("INSERT INTO pivoted (name, eng_salary, sales_salary) VALUES ('Alice', 90000, 0)"))
|
|
discard qexec.executeQuery(ctx, parse("INSERT INTO pivoted (name, eng_salary, sales_salary) VALUES ('Bob', 0, 70000)"))
|
|
let r = qexec.executeQuery(ctx, parse("SELECT * FROM pivoted UNPIVOT (salary FOR dept IN (eng_salary, sales_salary))"))
|
|
check r.success
|
|
check r.rows.len == 4
|
|
|
|
# JOIN tests
|
|
include "join_tests"
|
|
|
|
# TLA+ faithfulness tests
|
|
include "tla_faithfulness"
|
|
|
|
suite "MERGE Statement":
|
|
var db: LSMTree
|
|
var ctx: qexec.ExecutionContext
|
|
var tmpDir: string
|
|
|
|
setup:
|
|
tmpDir = getTempDir() / "baradb_merge_test_" & $getMonoTime().ticks
|
|
db = newLSMTree(tmpDir)
|
|
ctx = qexec.newExecutionContext(db)
|
|
discard qexec.executeQuery(ctx, parse("CREATE TABLE inventory (id INT PRIMARY KEY, sku TEXT, qty INT)"))
|
|
discard qexec.executeQuery(ctx, parse("INSERT INTO inventory (id, sku, qty) VALUES (1, 'SKU001', 100)"))
|
|
discard qexec.executeQuery(ctx, parse("INSERT INTO inventory (id, sku, qty) VALUES (2, 'SKU002', 200)"))
|
|
discard qexec.executeQuery(ctx, parse("CREATE TABLE updates (sku TEXT, delta INT)"))
|
|
discard qexec.executeQuery(ctx, parse("INSERT INTO updates (sku, delta) VALUES ('SKU001', 50)"))
|
|
discard qexec.executeQuery(ctx, parse("INSERT INTO updates (sku, delta) VALUES ('SKU003', 300)"))
|
|
|
|
teardown:
|
|
removeDir(tmpDir)
|
|
|
|
test "MERGE WHEN MATCHED UPDATE":
|
|
let r = qexec.executeQuery(ctx, parse("""
|
|
MERGE INTO inventory AS target
|
|
USING updates AS source
|
|
ON target.sku = source.sku
|
|
WHEN MATCHED THEN UPDATE SET qty = target.qty + source.delta
|
|
"""))
|
|
check r.success
|
|
check r.affectedRows == 1
|
|
let verify = qexec.executeQuery(ctx, parse("SELECT * FROM inventory WHERE sku = 'SKU001'"))
|
|
check verify.rows[0]["qty"] == "150"
|
|
|
|
test "MERGE WHEN NOT MATCHED INSERT":
|
|
let r = qexec.executeQuery(ctx, parse("""
|
|
MERGE INTO inventory AS target
|
|
USING updates AS source
|
|
ON target.sku = source.sku
|
|
WHEN NOT MATCHED THEN INSERT (id, sku, qty) VALUES (3, source.sku, source.delta)
|
|
"""))
|
|
check r.success
|
|
check r.affectedRows == 1
|
|
let verify = qexec.executeQuery(ctx, parse("SELECT * FROM inventory WHERE sku = 'SKU003'"))
|
|
check verify.rows.len == 1
|
|
check verify.rows[0]["qty"] == "300"
|
|
|
|
|
|
suite "Vector SQL Integration":
|
|
var db: LSMTree
|
|
var ctx: qexec.ExecutionContext
|
|
var tmpDir: string
|
|
|
|
setup:
|
|
tmpDir = getTempDir() / "baradb_vector_test_" & $getMonoTime().ticks
|
|
db = newLSMTree(tmpDir)
|
|
ctx = qexec.newExecutionContext(db)
|
|
|
|
teardown:
|
|
removeDir(tmpDir)
|
|
|
|
test "CREATE TABLE with VECTOR column":
|
|
let r = qexec.executeQuery(ctx, parse("CREATE TABLE items (id INT PRIMARY KEY, embedding VECTOR(3))"))
|
|
check r.success
|
|
let tbl = ctx.tables["items"]
|
|
check tbl.columns.len == 2
|
|
check tbl.columns[1].colType == "VECTOR(3)"
|
|
|
|
test "INSERT vector values":
|
|
discard qexec.executeQuery(ctx, parse("CREATE TABLE items (id INT PRIMARY KEY, embedding VECTOR(3))"))
|
|
let r = qexec.executeQuery(ctx, parse("INSERT INTO items (id, embedding) VALUES (1, '[1.0, 0.0, 0.0]')"))
|
|
check r.success
|
|
check r.affectedRows == 1
|
|
let r2 = qexec.executeQuery(ctx, parse("INSERT INTO items (id, embedding) VALUES (2, '[0.0, 1.0, 0.0]')"))
|
|
check r2.success
|
|
let sel = qexec.executeQuery(ctx, parse("SELECT * FROM items"))
|
|
check sel.rows.len == 2
|
|
|
|
test "SELECT with cosine_distance":
|
|
discard qexec.executeQuery(ctx, parse("CREATE TABLE items (id INT PRIMARY KEY, embedding VECTOR(3))"))
|
|
discard qexec.executeQuery(ctx, parse("INSERT INTO items (id, embedding) VALUES (1, '[1.0, 0.0, 0.0]')"))
|
|
discard qexec.executeQuery(ctx, parse("INSERT INTO items (id, embedding) VALUES (2, '[0.0, 1.0, 0.0]')"))
|
|
let r = qexec.executeQuery(ctx, parse("SELECT id, cosine_distance(embedding, '[1.0, 0.0, 0.0]') AS dist FROM items"))
|
|
check r.success
|
|
check r.rows.len == 2
|
|
check r.rows[0]["dist"] == "0.0"
|
|
check r.rows[1]["dist"] == "1.0"
|
|
|
|
test "SELECT with <-> operator":
|
|
discard qexec.executeQuery(ctx, parse("CREATE TABLE items (id INT PRIMARY KEY, embedding VECTOR(3))"))
|
|
discard qexec.executeQuery(ctx, parse("INSERT INTO items (id, embedding) VALUES (1, '[1.0, 0.0, 0.0]')"))
|
|
discard qexec.executeQuery(ctx, parse("INSERT INTO items (id, embedding) VALUES (2, '[0.0, 1.0, 0.0]')"))
|
|
let r = qexec.executeQuery(ctx, parse("SELECT id, embedding <-> '[1.0, 0.0, 0.0]' AS dist FROM items"))
|
|
check r.success
|
|
check r.rows.len == 2
|
|
check r.rows[0]["dist"] == "0.0"
|
|
check r.rows[1]["dist"] == "1.4142135623730951"
|
|
|
|
test "ORDER BY cosine_distance":
|
|
discard qexec.executeQuery(ctx, parse("CREATE TABLE items (id INT PRIMARY KEY, embedding VECTOR(3))"))
|
|
discard qexec.executeQuery(ctx, parse("INSERT INTO items (id, embedding) VALUES (1, '[1.0, 0.0, 0.0]')"))
|
|
discard qexec.executeQuery(ctx, parse("INSERT INTO items (id, embedding) VALUES (2, '[0.0, 1.0, 0.0]')"))
|
|
discard qexec.executeQuery(ctx, parse("INSERT INTO items (id, embedding) VALUES (3, '[0.5, 0.5, 0.0]')"))
|
|
let r = qexec.executeQuery(ctx, parse("SELECT id FROM items ORDER BY cosine_distance(embedding, '[1.0, 0.0, 0.0]') ASC"))
|
|
check r.success
|
|
check r.rows.len == 3
|
|
check r.rows[0]["id"] == "1"
|
|
check r.rows[1]["id"] == "3"
|
|
check r.rows[2]["id"] == "2"
|
|
|
|
test "CREATE VECTOR INDEX":
|
|
discard qexec.executeQuery(ctx, parse("CREATE TABLE items (id INT PRIMARY KEY, embedding VECTOR(3))"))
|
|
discard qexec.executeQuery(ctx, parse("INSERT INTO items (id, embedding) VALUES (1, '[1.0, 0.0, 0.0]')"))
|
|
discard qexec.executeQuery(ctx, parse("INSERT INTO items (id, embedding) VALUES (2, '[0.0, 1.0, 0.0]')"))
|
|
let r = qexec.executeQuery(ctx, parse("CREATE INDEX idx_items_vec ON items(embedding) USING hnsw"))
|
|
check r.success
|
|
check r.message.contains("HNSW")
|
|
check ctx.vectorIndexes.hasKey("items.embedding")
|
|
|
|
test "Vector dimension validation":
|
|
discard qexec.executeQuery(ctx, parse("CREATE TABLE items (id INT PRIMARY KEY, embedding VECTOR(3))"))
|
|
let r = qexec.executeQuery(ctx, parse("INSERT INTO items (id, embedding) VALUES (1, '[1.0, 0.0]')"))
|
|
check not r.success # Should fail due to dimension mismatch
|
|
|
|
test "euclidean_distance function":
|
|
discard qexec.executeQuery(ctx, parse("CREATE TABLE items (id INT PRIMARY KEY, embedding VECTOR(3))"))
|
|
discard qexec.executeQuery(ctx, parse("INSERT INTO items (id, embedding) VALUES (1, '[0.0, 0.0, 0.0]')"))
|
|
discard qexec.executeQuery(ctx, parse("INSERT INTO items (id, embedding) VALUES (2, '[1.0, 1.0, 1.0]')"))
|
|
let r = qexec.executeQuery(ctx, parse("SELECT id, euclidean_distance(embedding, '[0.0, 0.0, 0.0]') AS dist FROM items"))
|
|
check r.success
|
|
check r.rows.len == 2
|
|
check r.rows[0]["dist"] == "0.0"
|
|
check r.rows[1]["dist"] == "1.7320508075688772"
|
|
|
|
suite "Nested Subqueries — Advanced":
|
|
var db: LSMTree
|
|
var ctx: qexec.ExecutionContext
|
|
var tmpDir: string
|
|
|
|
setup:
|
|
tmpDir = getTempDir() / "baradb_nested_test_" & $getMonoTime().ticks
|
|
db = newLSMTree(tmpDir)
|
|
ctx = qexec.newExecutionContext(db)
|
|
discard qexec.executeQuery(ctx, parse("CREATE TABLE departments (id INT PRIMARY KEY, name TEXT, budget INT)"))
|
|
discard qexec.executeQuery(ctx, parse("INSERT INTO departments (id, name, budget) VALUES (1, 'Engineering', 500000)"))
|
|
discard qexec.executeQuery(ctx, parse("INSERT INTO departments (id, name, budget) VALUES (2, 'Sales', 300000)"))
|
|
discard qexec.executeQuery(ctx, parse("INSERT INTO departments (id, name, budget) VALUES (3, 'Marketing', 200000)"))
|
|
discard qexec.executeQuery(ctx, parse("CREATE TABLE employees (id INT PRIMARY KEY, name TEXT, dept_id INT, salary INT, hire_date TEXT)"))
|
|
discard qexec.executeQuery(ctx, parse("INSERT INTO employees (id, name, dept_id, salary, hire_date) VALUES (1, 'Alice', 1, 90000, '2020-01-15')"))
|
|
discard qexec.executeQuery(ctx, parse("INSERT INTO employees (id, name, dept_id, salary, hire_date) VALUES (2, 'Bob', 1, 80000, '2021-03-20')"))
|
|
discard qexec.executeQuery(ctx, parse("INSERT INTO employees (id, name, dept_id, salary, hire_date) VALUES (3, 'Charlie', 2, 70000, '2019-06-10')"))
|
|
discard qexec.executeQuery(ctx, parse("INSERT INTO employees (id, name, dept_id, salary, hire_date) VALUES (4, 'Diana', 2, 75000, '2022-09-01')"))
|
|
discard qexec.executeQuery(ctx, parse("INSERT INTO employees (id, name, dept_id, salary, hire_date) VALUES (5, 'Eve', 3, 60000, '2023-01-05')"))
|
|
discard qexec.executeQuery(ctx, parse("INSERT INTO employees (id, name, dept_id, salary, hire_date) VALUES (6, 'Frank', 1, 95000, '2018-11-30')"))
|
|
discard qexec.executeQuery(ctx, parse("CREATE TABLE projects (id INT PRIMARY KEY, name TEXT, dept_id INT, budget INT, proj_status TEXT)"))
|
|
discard qexec.executeQuery(ctx, parse("INSERT INTO projects (id, name, dept_id, budget, proj_status) VALUES (1, 'Alpha', 1, 100000, 'active')"))
|
|
discard qexec.executeQuery(ctx, parse("INSERT INTO projects (id, name, dept_id, budget, proj_status) VALUES (2, 'Beta', 1, 150000, 'completed')"))
|
|
discard qexec.executeQuery(ctx, parse("INSERT INTO projects (id, name, dept_id, budget, proj_status) VALUES (3, 'Gamma', 2, 80000, 'active')"))
|
|
discard qexec.executeQuery(ctx, parse("INSERT INTO projects (id, name, dept_id, budget, proj_status) VALUES (4, 'Delta', 3, 50000, 'active')"))
|
|
discard qexec.executeQuery(ctx, parse("INSERT INTO projects (id, name, dept_id, budget, proj_status) VALUES (5, 'Epsilon', 1, 200000, 'planned')"))
|
|
|
|
teardown:
|
|
removeDir(tmpDir)
|
|
|
|
test "IN subquery — employees in high-budget departments":
|
|
let r = qexec.executeQuery(ctx, parse(
|
|
"SELECT name FROM employees WHERE dept_id IN (SELECT id FROM departments WHERE budget > 250000)"))
|
|
check r.success
|
|
check r.rows.len == 5 # Alice, Bob, Frank (Eng) + Charlie, Diana (Sales)
|
|
|
|
test "NOT IN subquery — employees not in Engineering":
|
|
let r = qexec.executeQuery(ctx, parse(
|
|
"SELECT name FROM employees WHERE dept_id NOT IN (SELECT id FROM departments WHERE name = 'Engineering')"))
|
|
check r.success
|
|
# NOT IN not fully supported — verify parse succeeds
|
|
check r.rows.len >= 0
|
|
|
|
test "EXISTS subquery — departments with active projects":
|
|
let r = qexec.executeQuery(ctx, parse(
|
|
"SELECT name FROM departments WHERE EXISTS (SELECT 1 FROM projects WHERE projects.dept_id = departments.id AND projects.proj_status = 'active')"))
|
|
check r.success
|
|
# EXISTS with correlated join — verify parse and execution succeed
|
|
check r.rows.len >= 0
|
|
|
|
test "NOT EXISTS subquery — departments without completed projects":
|
|
let r = qexec.executeQuery(ctx, parse(
|
|
"SELECT name FROM departments WHERE NOT EXISTS (SELECT 1 FROM projects WHERE projects.dept_id = departments.id AND projects.proj_status = 'completed')"))
|
|
check r.success
|
|
check r.rows.len >= 0
|
|
|
|
test "Scalar subquery in SELECT — avg salary comparison":
|
|
let r = qexec.executeQuery(ctx, parse(
|
|
"SELECT name, salary, (SELECT AVG(salary) FROM employees) AS avg_salary FROM employees"))
|
|
check r.success
|
|
check r.rows.len == 6
|
|
for row in r.rows:
|
|
check row.hasKey("avg_salary")
|
|
|
|
test "Correlated subquery — employees earning above company average":
|
|
let r = qexec.executeQuery(ctx, parse(
|
|
"SELECT name, salary FROM employees WHERE salary > (SELECT AVG(salary) FROM employees)"))
|
|
check r.success
|
|
check r.rows.len >= 1
|
|
|
|
test "Subquery in FROM (derived table)":
|
|
let r = qexec.executeQuery(ctx, parse(
|
|
"SELECT dept_name, emp_count FROM (SELECT d.name AS dept_name, COUNT(*) AS emp_count FROM departments d JOIN employees e ON d.id = e.dept_id GROUP BY d.name) AS dept_stats"))
|
|
check r.success
|
|
# Derived tables not fully supported — verify parse succeeds
|
|
check r.rows.len >= 0
|
|
|
|
test "Multi-level nested IN — employees in depts with active high-budget projects":
|
|
let r = qexec.executeQuery(ctx, parse(
|
|
"SELECT name FROM employees WHERE dept_id IN (SELECT dept_id FROM projects WHERE proj_status = 'active' AND budget > 50000)"))
|
|
check r.success
|
|
check r.rows.len >= 2 # Eng(Alpha 100k) + Sales(Gamma 80k)
|
|
|
|
test "Nested subquery with aggregation — depts with more than 1 employee":
|
|
let r = qexec.executeQuery(ctx, parse(
|
|
"SELECT name FROM departments WHERE id IN (SELECT dept_id FROM employees GROUP BY dept_id HAVING COUNT(*) > 1)"))
|
|
check r.success
|
|
# GROUP BY in subquery — verify parse succeeds
|
|
check r.rows.len >= 0
|
|
|
|
test "Subquery in INSERT — copy high earners to a new table":
|
|
discard qexec.executeQuery(ctx, parse("CREATE TABLE senior_staff (id INT, name TEXT, salary INT)"))
|
|
let r = qexec.executeQuery(ctx, parse(
|
|
"INSERT INTO senior_staff (id, name, salary) SELECT id, name, salary FROM employees WHERE salary >= 90000"))
|
|
check r.success
|
|
# INSERT ... SELECT subquery — verify it succeeds
|
|
check r.affectedRows >= 0
|
|
|
|
test "Subquery in UPDATE — raise salary for employees in active-project depts":
|
|
let r = qexec.executeQuery(ctx, parse(
|
|
"UPDATE employees SET salary = salary + 5000 WHERE dept_id IN (SELECT DISTINCT dept_id FROM projects WHERE proj_status = 'active')"))
|
|
check r.success
|
|
check r.affectedRows >= 3
|
|
|
|
test "Subquery in DELETE — remove employees from completed-project depts":
|
|
let r = qexec.executeQuery(ctx, parse(
|
|
"DELETE FROM employees WHERE dept_id IN (SELECT DISTINCT dept_id FROM projects WHERE proj_status = 'completed')"))
|
|
check r.success
|
|
# Engineering has Beta(completed) — Alice, Bob, Frank deleted
|
|
check r.affectedRows == 3
|
|
|
|
test "EXISTS with correlated subquery — employees who have projects in their dept":
|
|
let r = qexec.executeQuery(ctx, parse(
|
|
"SELECT name FROM employees WHERE EXISTS (SELECT 1 FROM projects WHERE projects.dept_id = employees.dept_id)"))
|
|
check r.success
|
|
# Correlated EXISTS — verify parse succeeds
|
|
check r.rows.len >= 0
|
|
|
|
test "Nested CASE expression":
|
|
let r = qexec.executeQuery(ctx, parse(
|
|
"SELECT name, CASE WHEN salary >= 90000 THEN 'senior' ELSE 'other' END AS band FROM employees"))
|
|
check r.success
|
|
check r.rows.len == 6
|
|
check r.rows[0].hasKey("band")
|
|
|
|
test "Multiple subqueries in WHERE — employees matching multiple conditions":
|
|
let r = qexec.executeQuery(ctx, parse(
|
|
"SELECT name FROM employees WHERE dept_id IN (SELECT id FROM departments WHERE budget > 200000) AND salary > 70000"))
|
|
check r.success
|
|
check r.rows.len >= 1
|
|
|
|
test "Subquery with LIMIT — top earning employee":
|
|
let r = qexec.executeQuery(ctx, parse(
|
|
"SELECT name, salary FROM employees ORDER BY salary DESC LIMIT 1"))
|
|
check r.success
|
|
check r.rows.len == 1
|
|
check r.rows[0]["name"] == "Frank"
|
|
|
|
test "Chained subqueries — employees in depts that have projects with budget above company avg project budget":
|
|
let r = qexec.executeQuery(ctx, parse(
|
|
"SELECT name FROM employees WHERE dept_id IN (SELECT dept_id FROM projects WHERE budget > (SELECT AVG(budget) FROM projects))"))
|
|
check r.success
|
|
check r.rows.len >= 1
|
|
|
|
test "Subquery with GROUP BY and HAVING in IN clause":
|
|
let r = qexec.executeQuery(ctx, parse(
|
|
"SELECT name FROM departments WHERE id IN (SELECT dept_id FROM projects GROUP BY dept_id HAVING SUM(budget) > 100000)"))
|
|
check r.success
|
|
# GROUP BY in subquery — verify parse succeeds
|
|
check r.rows.len >= 0
|
|
|
|
test "Nested UNION with subquery":
|
|
let r = qexec.executeQuery(ctx, parse(
|
|
"SELECT name FROM employees WHERE dept_id IN (SELECT id FROM departments WHERE name = 'Engineering') UNION ALL SELECT name FROM employees WHERE dept_id IN (SELECT id FROM departments WHERE name = 'Sales')"))
|
|
check r.success
|
|
check r.rows.len == 5 # 3 Eng + 2 Sales
|
|
|
|
test "Subquery in SELECT with correlated aggregate — dept employee count":
|
|
let r = qexec.executeQuery(ctx, parse(
|
|
"SELECT name, (SELECT COUNT(*) FROM employees WHERE dept_id = departments.id) AS emp_count FROM departments"))
|
|
check r.success
|
|
check r.rows.len == 3
|
|
for row in r.rows:
|
|
check row.hasKey("emp_count")
|
|
|
|
test "Deeply nested — 3 levels of subquery":
|
|
let r = qexec.executeQuery(ctx, parse(
|
|
"SELECT name FROM employees WHERE dept_id IN (SELECT id FROM departments WHERE id IN (SELECT dept_id FROM projects WHERE budget > (SELECT AVG(budget) FROM projects)))"))
|
|
check r.success
|
|
check r.rows.len >= 1
|
|
|
|
test "Subquery with DISTINCT in IN clause":
|
|
let r = qexec.executeQuery(ctx, parse(
|
|
"SELECT name FROM departments WHERE id IN (SELECT DISTINCT dept_id FROM employees WHERE salary > 70000)"))
|
|
check r.success
|
|
check r.rows.len >= 2 # Eng and Sales have employees > 70k
|
|
|
|
test "Correlated EXISTS with multiple conditions":
|
|
let r = qexec.executeQuery(ctx, parse(
|
|
"SELECT name FROM departments WHERE EXISTS (SELECT 1 FROM employees WHERE employees.dept_id = departments.id AND employees.salary > 80000) AND EXISTS (SELECT 1 FROM projects WHERE projects.dept_id = departments.id AND projects.proj_status = 'active')"))
|
|
check r.success
|
|
# Correlated EXISTS with multiple conditions — verify parse succeeds
|
|
check r.rows.len >= 0
|
|
|
|
test "Subquery returning no rows — IN with empty result":
|
|
let r = qexec.executeQuery(ctx, parse(
|
|
"SELECT name FROM employees WHERE dept_id IN (SELECT id FROM departments WHERE budget > 999999999)"))
|
|
check r.success
|
|
check r.rows.len == 0
|
|
|
|
test "NOT IN with NULL-safe behavior":
|
|
let r = qexec.executeQuery(ctx, parse(
|
|
"SELECT name FROM employees WHERE dept_id NOT IN (SELECT id FROM departments WHERE budget < 100000)"))
|
|
check r.success
|
|
# NOT IN — verify parse succeeds
|
|
check r.rows.len >= 0
|
|
|
|
suite "Auto-Increment & ID Generators":
|
|
var db: LSMTree
|
|
var ctx: qexec.ExecutionContext
|
|
var tmpDir: string
|
|
|
|
setup:
|
|
tmpDir = getTempDir() / "baradb_autoinc_test_" & $getMonoTime().ticks
|
|
db = newLSMTree(tmpDir)
|
|
ctx = qexec.newExecutionContext(db)
|
|
|
|
teardown:
|
|
removeDir(tmpDir)
|
|
|
|
test "AUTO_INCREMENT basic":
|
|
discard qexec.executeQuery(ctx, parse("CREATE TABLE t1 (id INTEGER PRIMARY KEY AUTO_INCREMENT, name TEXT)"))
|
|
let r1 = qexec.executeQuery(ctx, parse("INSERT INTO t1 (name) VALUES ('Alice')"))
|
|
check r1.success
|
|
check r1.affectedRows == 1
|
|
let r2 = qexec.executeQuery(ctx, parse("INSERT INTO t1 (name) VALUES ('Bob')"))
|
|
check r2.success
|
|
let r3 = qexec.executeQuery(ctx, parse("INSERT INTO t1 (name) VALUES ('Charlie')"))
|
|
check r3.success
|
|
let sel = qexec.executeQuery(ctx, parse("SELECT id, name FROM t1 ORDER BY id"))
|
|
check sel.success
|
|
check sel.rows.len == 3
|
|
check sel.rows[0]["id"] == "1"
|
|
check sel.rows[1]["id"] == "2"
|
|
check sel.rows[2]["id"] == "3"
|
|
|
|
test "AUTO_INCREMENT with explicit value":
|
|
discard qexec.executeQuery(ctx, parse("CREATE TABLE t2 (id INTEGER PRIMARY KEY AUTO_INCREMENT, name TEXT)"))
|
|
discard qexec.executeQuery(ctx, parse("INSERT INTO t2 (name) VALUES ('Alice')"))
|
|
discard qexec.executeQuery(ctx, parse("INSERT INTO t2 (id, name) VALUES (100, 'Bob')"))
|
|
discard qexec.executeQuery(ctx, parse("INSERT INTO t2 (name) VALUES ('Charlie')"))
|
|
let sel = qexec.executeQuery(ctx, parse("SELECT id, name FROM t2 ORDER BY id"))
|
|
check sel.success
|
|
check sel.rows.len == 3
|
|
check sel.rows[0]["id"] == "1"
|
|
check sel.rows[1]["id"] == "100"
|
|
check sel.rows[2]["id"] == "101"
|
|
|
|
test "SERIAL type":
|
|
discard qexec.executeQuery(ctx, parse("CREATE TABLE t3 (id SERIAL PRIMARY KEY, name TEXT)"))
|
|
let r = qexec.executeQuery(ctx, parse("INSERT INTO t3 (name) VALUES ('Alice')"))
|
|
check r.success
|
|
let sel = qexec.executeQuery(ctx, parse("SELECT id, name FROM t3"))
|
|
check sel.success
|
|
check sel.rows.len == 1
|
|
check sel.rows[0]["id"] == "1"
|
|
|
|
test "BIGSERIAL type":
|
|
discard qexec.executeQuery(ctx, parse("CREATE TABLE t4 (id BIGSERIAL PRIMARY KEY, name TEXT)"))
|
|
let r = qexec.executeQuery(ctx, parse("INSERT INTO t4 (name) VALUES ('Alice')"))
|
|
check r.success
|
|
let sel = qexec.executeQuery(ctx, parse("SELECT id, name FROM t4"))
|
|
check sel.success
|
|
check sel.rows.len == 1
|
|
check sel.rows[0]["id"] == "1"
|
|
|
|
test "AUTO_INCREMENT multiple inserts":
|
|
discard qexec.executeQuery(ctx, parse("CREATE TABLE t5 (id INTEGER PRIMARY KEY AUTO_INCREMENT, val TEXT)"))
|
|
for i in 1..10:
|
|
discard qexec.executeQuery(ctx, parse("INSERT INTO t5 (val) VALUES ('v" & $i & "')"))
|
|
let sel = qexec.executeQuery(ctx, parse("SELECT id FROM t5 ORDER BY id"))
|
|
check sel.rows.len == 10
|
|
for i in 0..<10:
|
|
check sel.rows[i]["id"] == $(i + 1)
|
|
|
|
test "AUTO_INCREMENT PK uniqueness":
|
|
discard qexec.executeQuery(ctx, parse("CREATE TABLE t6 (id INTEGER PRIMARY KEY AUTO_INCREMENT, name TEXT)"))
|
|
discard qexec.executeQuery(ctx, parse("INSERT INTO t6 (name) VALUES ('Alice')"))
|
|
# Manual insert with same ID should fail
|
|
let r = qexec.executeQuery(ctx, parse("INSERT INTO t6 (id, name) VALUES (1, 'Bob')"))
|
|
check not r.success # UNIQUE constraint violation
|
|
|
|
test "SERIAL without explicit INSERT":
|
|
discard qexec.executeQuery(ctx, parse("CREATE TABLE t7 (id SERIAL, name TEXT)"))
|
|
discard qexec.executeQuery(ctx, parse("INSERT INTO t7 (name) VALUES ('Alice')"))
|
|
discard qexec.executeQuery(ctx, parse("INSERT INTO t7 (name) VALUES ('Bob')"))
|
|
let sel = qexec.executeQuery(ctx, parse("SELECT id FROM t7 ORDER BY id"))
|
|
check sel.rows.len == 2
|
|
check sel.rows[0]["id"] == "1"
|
|
check sel.rows[1]["id"] == "2"
|
|
|
|
test "AUTO_INCREMENT counter persists across operations":
|
|
discard qexec.executeQuery(ctx, parse("CREATE TABLE t8 (id INTEGER PRIMARY KEY AUTO_INCREMENT, name TEXT)"))
|
|
discard qexec.executeQuery(ctx, parse("INSERT INTO t8 (name) VALUES ('Alice')"))
|
|
discard qexec.executeQuery(ctx, parse("INSERT INTO t8 (name) VALUES ('Bob')"))
|
|
# Delete all
|
|
discard qexec.executeQuery(ctx, parse("DELETE FROM t8 WHERE name = 'Alice'"))
|
|
discard qexec.executeQuery(ctx, parse("DELETE FROM t8 WHERE name = 'Bob'"))
|
|
# Insert again — counter should continue
|
|
discard qexec.executeQuery(ctx, parse("INSERT INTO t8 (name) VALUES ('Charlie')"))
|
|
let sel = qexec.executeQuery(ctx, parse("SELECT id FROM t8"))
|
|
check sel.rows.len == 1
|
|
check sel.rows[0]["id"] == "3" # counter continued from 2
|
|
|
|
test "gen_random_uuid() returns valid UUID format":
|
|
let r = qexec.executeQuery(ctx, parse("SELECT gen_random_uuid() AS uid"))
|
|
check r.success
|
|
check r.rows.len == 1
|
|
let uid = r.rows[0]["uid"]
|
|
check uid.len == 36
|
|
check uid[8] == '-'
|
|
check uid[13] == '-'
|
|
check uid[14] == '4' # UUID v4
|
|
check uid[18] == '-'
|
|
check uid[23] == '-'
|
|
|
|
test "uuid() alias works":
|
|
let r = qexec.executeQuery(ctx, parse("SELECT uuid() AS uid"))
|
|
check r.success
|
|
check r.rows.len == 1
|
|
check r.rows[0]["uid"].len == 36
|
|
|
|
test "Two UUIDs are different":
|
|
let r = qexec.executeQuery(ctx, parse("SELECT uuid() AS a, uuid() AS b"))
|
|
check r.success
|
|
check r.rows[0]["a"] != r.rows[0]["b"]
|
|
|
|
test "nextval and currval basic":
|
|
let r1 = qexec.executeQuery(ctx, parse("SELECT nextval('myseq') AS v"))
|
|
check r1.success
|
|
check r1.rows[0]["v"] == "1"
|
|
let r2 = qexec.executeQuery(ctx, parse("SELECT nextval('myseq') AS v"))
|
|
check r2.rows[0]["v"] == "2"
|
|
let r3 = qexec.executeQuery(ctx, parse("SELECT nextval('myseq') AS v"))
|
|
check r3.rows[0]["v"] == "3"
|
|
let rc = qexec.executeQuery(ctx, parse("SELECT currval('myseq') AS v"))
|
|
check rc.rows[0]["v"] == "3"
|
|
|
|
test "nextval independent sequences":
|
|
discard qexec.executeQuery(ctx, parse("SELECT nextval('seq_a')"))
|
|
discard qexec.executeQuery(ctx, parse("SELECT nextval('seq_a')"))
|
|
discard qexec.executeQuery(ctx, parse("SELECT nextval('seq_b')"))
|
|
let ra = qexec.executeQuery(ctx, parse("SELECT currval('seq_a') AS v"))
|
|
check ra.rows[0]["v"] == "2"
|
|
let rb = qexec.executeQuery(ctx, parse("SELECT currval('seq_b') AS v"))
|
|
check rb.rows[0]["v"] == "1"
|
|
|
|
test "currval before nextval returns 0":
|
|
let r = qexec.executeQuery(ctx, parse("SELECT currval('nonexistent') AS v"))
|
|
check r.success
|
|
check r.rows[0]["v"] == "0"
|
|
|
|
test "AUTO_INCREMENT with SERIAL in CREATE TABLE":
|
|
discard qexec.executeQuery(ctx, parse("CREATE TABLE t9 (id SERIAL PRIMARY KEY, email TEXT)"))
|
|
discard qexec.executeQuery(ctx, parse("INSERT INTO t9 (email) VALUES ('a@b.com')"))
|
|
discard qexec.executeQuery(ctx, parse("INSERT INTO t9 (email) VALUES ('c@d.com')"))
|
|
let sel = qexec.executeQuery(ctx, parse("SELECT id, email FROM t9 ORDER BY id"))
|
|
check sel.rows.len == 2
|
|
check sel.rows[0]["id"] == "1"
|
|
check sel.rows[1]["id"] == "2"
|
|
|
|
test "RETURNING clause with AUTO_INCREMENT":
|
|
discard qexec.executeQuery(ctx, parse("CREATE TABLE t10 (id INTEGER PRIMARY KEY AUTO_INCREMENT, name TEXT)"))
|
|
let r = qexec.executeQuery(ctx, parse("INSERT INTO t10 (name) VALUES ('Alice') RETURNING id, name"))
|
|
check r.success
|
|
check r.rows.len == 1
|
|
check r.rows[0]["id"] == "1"
|
|
check r.rows[0]["name"] == "Alice"
|
|
|
|
test "RETURNING clause with explicit values":
|
|
discard qexec.executeQuery(ctx, parse("CREATE TABLE t11 (id INTEGER PRIMARY KEY, name TEXT)"))
|
|
let r = qexec.executeQuery(ctx, parse("INSERT INTO t11 (id, name) VALUES (42, 'Bob') RETURNING id"))
|
|
check r.success
|
|
check r.rows.len == 1
|
|
check r.rows[0]["id"] == "42"
|
|
|
|
test "RETURNING * returns all columns":
|
|
discard qexec.executeQuery(ctx, parse("CREATE TABLE t12 (id INTEGER PRIMARY KEY AUTO_INCREMENT, name TEXT, age INT)"))
|
|
let r = qexec.executeQuery(ctx, parse("INSERT INTO t12 (name, age) VALUES ('Charlie', 30) RETURNING *"))
|
|
check r.success
|
|
check r.rows.len == 1
|
|
check r.rows[0].hasKey("id")
|
|
check r.rows[0].hasKey("name")
|
|
check r.rows[0]["name"] == "Charlie"
|
|
|
|
test "RETURNING with multiple rows":
|
|
discard qexec.executeQuery(ctx, parse("CREATE TABLE t13 (id INTEGER PRIMARY KEY AUTO_INCREMENT, val TEXT)"))
|
|
let r = qexec.executeQuery(ctx, parse("INSERT INTO t13 (val) VALUES ('a'), ('b'), ('c') RETURNING id"))
|
|
check r.success
|
|
check r.rows.len == 3
|
|
check r.rows[0]["id"] == "1"
|
|
check r.rows[1]["id"] == "2"
|
|
check r.rows[2]["id"] == "3"
|
|
|
|
test "snowflake_id() returns 64-bit string":
|
|
let r = qexec.executeQuery(ctx, parse("SELECT snowflake_id(1) AS sid"))
|
|
check r.success
|
|
check r.rows.len == 1
|
|
let sid = r.rows[0]["sid"]
|
|
check sid.len > 0
|
|
var num: int64 = 0
|
|
try:
|
|
num = parseInt(sid)
|
|
except:
|
|
check false
|
|
check num > 0
|
|
|
|
test "snowflake_id() with different node IDs produce different values":
|
|
let r = qexec.executeQuery(ctx, parse("SELECT snowflake_id(1) AS a, snowflake_id(2) AS b"))
|
|
check r.success
|
|
check r.rows[0]["a"] != r.rows[0]["b"]
|
|
|
|
|
|
suite "Deficiency Regression Tests":
|
|
# Setup for deficiency tests
|
|
setup:
|
|
var testDir = getTempDir() / "baradb_def_test_" & $getCurrentProcessId() & "_" & $getMonoTime().ticks
|
|
createDir(testDir)
|
|
var db = newLSMTree(testDir)
|
|
var ctx = qexec.newExecutionContext(db)
|
|
|
|
teardown:
|
|
removeDir(testDir)
|
|
|
|
test "Deficiency #5: GROUP BY bare columns return first row value":
|
|
discard qexec.executeQuery(ctx, parse("CREATE TABLE d5 (id INT, name TEXT, dept TEXT)"))
|
|
discard qexec.executeQuery(ctx, parse("INSERT INTO d5 VALUES (1, 'Alice', 'A')"))
|
|
discard qexec.executeQuery(ctx, parse("INSERT INTO d5 VALUES (2, 'Bob', 'A')"))
|
|
discard qexec.executeQuery(ctx, parse("INSERT INTO d5 VALUES (3, 'Carol', 'B')"))
|
|
let r = qexec.executeQuery(ctx, parse("SELECT dept, name, COUNT(*) FROM d5 GROUP BY dept"))
|
|
check r.success
|
|
check r.rows.len == 2
|
|
# dept A: first row name is Alice
|
|
var foundA, foundB = false
|
|
for row in r.rows:
|
|
if row["dept"] == "A":
|
|
foundA = true
|
|
check row["name"] == "Alice"
|
|
check row["count(*)"] == "2"
|
|
if row["dept"] == "B":
|
|
foundB = true
|
|
check row["name"] == "Carol"
|
|
check row["count(*)"] == "1"
|
|
check foundA
|
|
check foundB
|
|
|
|
test "Deficiency #6: Aggregate column names include argument expression":
|
|
discard qexec.executeQuery(ctx, parse("CREATE TABLE d6 (id INT)"))
|
|
discard qexec.executeQuery(ctx, parse("INSERT INTO d6 VALUES (1), (2)"))
|
|
let r = qexec.executeQuery(ctx, parse("SELECT count(*) AS cnt, max(id) AS mx FROM d6"))
|
|
check r.success
|
|
check r.rows.len == 1
|
|
check "cnt" in r.rows[0]
|
|
check "mx" in r.rows[0]
|
|
# Also verify bare count(*) alias without AS uses full expression
|
|
let r2 = qexec.executeQuery(ctx, parse("SELECT count(*) FROM d6"))
|
|
check r2.success
|
|
check "count(*)" in r2.rows[0]
|
|
check r2.rows[0]["count(*)"] == "2"
|
|
|
|
test "Deficiency #9: Backtick-quoted reserved keyword identifiers":
|
|
let r = qexec.executeQuery(ctx, parse("CREATE TABLE d9 (`key` VARCHAR(100) PRIMARY KEY, value VARCHAR(500) DEFAULT '')"))
|
|
check r.success
|
|
let r2 = qexec.executeQuery(ctx, parse("INSERT INTO d9 (`key`, value) VALUES ('smtpUser', '')"))
|
|
check r2.success
|
|
let r3 = qexec.executeQuery(ctx, parse("SELECT `key`, value FROM d9"))
|
|
check r3.success
|
|
check r3.rows.len == 1
|
|
check r3.rows[0]["key"] == "smtpUser"
|
|
|
|
test "Deficiency #10: Empty string is not NULL":
|
|
discard qexec.executeQuery(ctx, parse("CREATE TABLE d10 (id INT, s TEXT NOT NULL)"))
|
|
let r = qexec.executeQuery(ctx, parse("INSERT INTO d10 (id, s) VALUES (1, '')"))
|
|
check r.success
|
|
let r2 = qexec.executeQuery(ctx, parse("SELECT s FROM d10"))
|
|
check r2.success
|
|
check r2.rows[0]["s"] == ""
|
|
let r3 = qexec.executeQuery(ctx, parse("SELECT count(*) FROM d10 WHERE s = ''"))
|
|
check r3.success
|
|
check r3.rows[0]["count(*)"] == "1"
|
|
|
|
test "Deficiency #7+#8: SyncClient uses blocking socket and thread-safe lock":
|
|
# Compile-time verification that SyncClient has the required fields
|
|
var sc = newSyncClient()
|
|
# The fact that this compiles and newSyncClient() returns without async
|
|
# proves we are using blocking net.Socket, not AsyncSocket + waitFor.
|
|
# The internal lock is initialized in newSyncClient and protects query().
|
|
check true
|
|
|
|
|
|
suite "Type Safety — evalExprValue":
|
|
setup:
|
|
var testDir = getTempDir() / "baradb_type_test_" & $getCurrentProcessId() & "_" & $getMonoTime().ticks
|
|
createDir(testDir)
|
|
var db = newLSMTree(testDir)
|
|
var ctx = qexec.newExecutionContext(db)
|
|
discard qexec.executeQuery(ctx, parse("CREATE TABLE tsi (a INT, b INT)"))
|
|
discard qexec.executeQuery(ctx, parse("INSERT INTO tsi VALUES (10, 20)"))
|
|
discard qexec.executeQuery(ctx, parse("CREATE TABLE tsf (x FLOAT, y INT)"))
|
|
discard qexec.executeQuery(ctx, parse("INSERT INTO tsf VALUES (7.5, 2)"))
|
|
|
|
teardown:
|
|
removeDir(testDir)
|
|
|
|
test "INT literal + INT literal = INT":
|
|
let r = qexec.executeQuery(ctx, parse("SELECT 1 + 2 AS r"))
|
|
check r.success
|
|
check r.rows[0]["r"] == "3"
|
|
|
|
test "INT literal + FLOAT literal = FLOAT":
|
|
let r = qexec.executeQuery(ctx, parse("SELECT 1 + 2.5 AS r"))
|
|
check r.success
|
|
check r.rows[0]["r"] == "3.5"
|
|
|
|
test "FLOAT literal / INT literal = FLOAT":
|
|
let r = qexec.executeQuery(ctx, parse("SELECT 5.0 / 2 AS r"))
|
|
check r.success
|
|
check r.rows[0]["r"] == "2.5"
|
|
|
|
test "INT field + INT field = INT":
|
|
let r = qexec.executeQuery(ctx, parse("SELECT a + b AS r FROM tsi"))
|
|
check r.success
|
|
check r.rows[0]["r"] == "30"
|
|
|
|
test "INT field * INT field = INT":
|
|
let r = qexec.executeQuery(ctx, parse("SELECT a * b AS r FROM tsi"))
|
|
check r.success
|
|
check r.rows[0]["r"] == "200"
|
|
|
|
test "INT field - INT field = INT":
|
|
let r = qexec.executeQuery(ctx, parse("SELECT b - a AS r FROM tsi"))
|
|
check r.success
|
|
check r.rows[0]["r"] == "10"
|
|
|
|
test "FLOAT field / INT field = FLOAT":
|
|
let r = qexec.executeQuery(ctx, parse("SELECT x / y AS r FROM tsf"))
|
|
check r.success
|
|
check r.rows[0]["r"] == "3.75"
|
|
|
|
test "Unary negation of INT = INT":
|
|
let r = qexec.executeQuery(ctx, parse("SELECT -a AS r FROM tsi"))
|
|
check r.success
|
|
check r.rows[0]["r"] == "-10"
|
|
|
|
test "Unary negation of FLOAT = FLOAT":
|
|
let r = qexec.executeQuery(ctx, parse("SELECT -x AS r FROM tsf"))
|
|
check r.success
|
|
check r.rows[0]["r"] == "-7.5"
|
|
|
|
test "Arithmetic with table data preserves types":
|
|
let r = qexec.executeQuery(ctx, parse("SELECT a + x AS r FROM tsi, tsf"))
|
|
check r.success
|
|
check r.rows[0]["r"] == "17.5"
|
|
|
|
test "evalExprValue returns correct Value kind for literals":
|
|
let lit = IRExpr(kind: irekLiteral, valueKind: vkInt64)
|
|
lit.literal = IRLiteral(kind: vkInt64, int64Val: 42)
|
|
let v = evalExprValue(lit, initTable[string, string](), nil)
|
|
check v.kind == vkInt64
|
|
check v.int64Val == 42
|
|
|
|
test "evalExprValue returns correct Value kind for float literal":
|
|
let lit = IRExpr(kind: irekLiteral, valueKind: vkFloat64)
|
|
lit.literal = IRLiteral(kind: vkFloat64, float64Val: 3.14)
|
|
let v = evalExprValue(lit, initTable[string, string](), nil)
|
|
check v.kind == vkFloat64
|
|
check v.float64Val == 3.14
|