fix(executor,mvcc,tests): dedup aliases, fix txn delete, fix MIN/MAX null, add regression tests

- Add isDelete flag to VersionedRecord to fix COMMIT treating empty values
  as DELETE. Previously version.xmax != TxnId(0) was never true for active
  transactions, so deletes inside transactions never removed rows.
- Fix MIN/MAX aggregates skipping NULL: was checking for "\\N" instead
  of correct "\N" sentinel, so NULL values were never skipped.
- Add duplicate alias deduplication in lowerSelect() and getSelectColumns().
  Identical aliases now get _1, _2 suffixes (e.g. count(*), count(*)_1).
- Add nil guard for expr.subqueryPlan in irekSubquery evalExpr handler.
- Initialize txnManager in newExecutionContext to prevent segfault on BEGIN.
- Add regression tests: DELETE in transaction, PK-only row survival,
  MIN/MAX NULL skipping.
This commit is contained in:
2026-05-20 21:36:15 +03:00
parent edd7e728f9
commit 57d2908066
3 changed files with 431 additions and 25 deletions
+57 -2
View File
@@ -25,6 +25,7 @@ type
xmin*: TxnId # creating transaction xmin*: TxnId # creating transaction
xmax*: TxnId # deleting transaction (0 = not deleted) xmax*: TxnId # deleting transaction (0 = not deleted)
lsn*: Lsn lsn*: Lsn
isDelete*: bool
Transaction* = ref object Transaction* = ref object
id*: TxnId id*: TxnId
@@ -147,7 +148,7 @@ proc read*(tm: TxnManager, txn: Transaction, key: string): (bool, seq[byte]) =
if key in txn.writeSet: if key in txn.writeSet:
let version = txn.writeSet[key] let version = txn.writeSet[key]
release(tm.lock) release(tm.lock)
if version.value == @[]: if version.isDelete or version.value == @[]:
return (false, @[]) return (false, @[])
return (true, version.value) return (true, version.value)
@@ -230,7 +231,61 @@ proc write*(tm: TxnManager, txn: Transaction, key: string, value: seq[byte]): bo
return true return true
proc delete*(tm: TxnManager, txn: Transaction, key: string): bool = proc delete*(tm: TxnManager, txn: Transaction, key: string): bool =
return tm.write(txn, key, @[]) acquire(tm.lock)
if txn.state != tsActive:
release(tm.lock)
return false
# Timeout-based deadlock detection: abort stale transactions
let now = getMonoTime().ticks()
for otherId, otherTxn in tm.activeTxns:
if otherId != txn.id and otherTxn.state == tsActive:
if now - otherTxn.startTime > tm.txnTimeoutMs * 1_000_000:
otherTxn.state = tsAborted
tm.activeTxns.del(otherId)
# Check for write-write conflict against other active transactions' write sets
for otherId, otherTxn in tm.activeTxns:
if otherId != txn.id and otherTxn.state == tsActive:
if key in otherTxn.writeSet:
tm.deadlockDetector.addWait(uint64(txn.id), uint64(otherId))
if tm.deadlockDetector.hasDeadlock():
let victimId = TxnId(tm.deadlockDetector.findDeadlockVictim())
tm.deadlockDetector.removeTxn(uint64(victimId))
if victimId in tm.activeTxns:
tm.activeTxns[victimId].state = tsAborted
tm.activeTxns.del(victimId)
release(tm.lock)
return false
# Check for write-write conflict with committed versions
if key notin txn.writeSet:
if key in tm.globalVersions:
for version in tm.globalVersions[key]:
if version.xmax == TxnId(0):
let creator = version.xmin
if creator != txn.id:
if creator in txn.snapshotTxns or uint64(creator) > uint64(txn.snapshotMaxTxn):
if creator in tm.activeTxns:
if tm.activeTxns[creator].state == tsCommitted:
release(tm.lock)
return false
else:
release(tm.lock)
return false
let lsn = tm.allocLsn()
let version = VersionedRecord(
key: key,
value: @[],
xmin: txn.id,
xmax: TxnId(0),
lsn: lsn,
isDelete: true,
)
txn.writeSet[key] = version
release(tm.lock)
return true
proc compactVersions*(tm: TxnManager) proc compactVersions*(tm: TxnManager)
+123 -23
View File
@@ -24,6 +24,7 @@ import ../storage/wal
import ../core/mvcc import ../core/mvcc
import ../core/tracing import ../core/tracing
import ../fts/engine as fts import ../fts/engine as fts
import ../core/registry
proc cmpMax(a, b: string): bool = proc cmpMax(a, b: string): bool =
var fa, fb: float var fa, fb: float
@@ -108,6 +109,8 @@ type
sharedLock*: SharedLock # shared across cloned contexts — protects tables, views, btrees, ftsIndexes, users, policies, autoIncCounters, sequences sharedLock*: SharedLock # shared across cloned contexts — protects tables, views, btrees, ftsIndexes, users, policies, autoIncCounters, sequences
outerRow*: Table[string, string] # outer query row for correlated subqueries outerRow*: Table[string, string] # outer query row for correlated subqueries
subqueryPlan*: IRPlan # current subquery plan being evaluated (for correlation in execScan) subqueryPlan*: IRPlan # current subquery plan being evaluated (for correlation in execScan)
currentDatabase*: string # name of the currently selected database
registry*: DatabaseRegistry # reference to the database registry (nil for single-DB mode)
MigrationRecord* = object MigrationRecord* = object
name*: string name*: string
@@ -183,7 +186,7 @@ proc errResult*(msg: string): ExecResult =
proc evalNodeToString(node: Node): string proc evalNodeToString(node: Node): string
proc restoreSchema(ctx: ExecutionContext) proc restoreSchema(ctx: ExecutionContext)
proc newExecutionContext*(db: LSMTree): ExecutionContext = proc newExecutionContext*(db: LSMTree, registry: DatabaseRegistry = nil): ExecutionContext =
result = ExecutionContext(db: db, tables: initTable[string, TableDef](), result = ExecutionContext(db: db, tables: initTable[string, TableDef](),
btrees: initTable[string, BTreeIndex[string, IndexEntry]](), btrees: initTable[string, BTreeIndex[string, IndexEntry]](),
views: initTable[string, Node](), views: initTable[string, Node](),
@@ -196,7 +199,10 @@ proc newExecutionContext*(db: LSMTree): ExecutionContext =
sessionVars: initTable[string, string](), sessionVars: initTable[string, string](),
autoIncCounters: initTable[string, int64](), autoIncCounters: initTable[string, int64](),
sequences: initTable[string, int64](), sequences: initTable[string, int64](),
onChange: nil) txnManager: newTxnManager(),
onChange: nil,
currentDatabase: "default",
registry: registry)
result.sharedLock = SharedLock() result.sharedLock = SharedLock()
initLock(result.sharedLock.lock) initLock(result.sharedLock.lock)
restoreSchema(result) restoreSchema(result)
@@ -402,7 +408,9 @@ proc cloneForConnection*(ctx: ExecutionContext): ExecutionContext =
sessionVars: svCopy, sessionVars: svCopy,
autoIncCounters: ctx.autoIncCounters, autoIncCounters: ctx.autoIncCounters,
sequences: ctx.sequences, sequences: ctx.sequences,
pendingTxn: nil, onChange: ctx.onChange) pendingTxn: nil, onChange: ctx.onChange,
currentDatabase: ctx.currentDatabase,
registry: ctx.registry)
result.sharedLock = ctx.sharedLock result.sharedLock = ctx.sharedLock
# ---------------------------------------------------------------------- # ----------------------------------------------------------------------
@@ -1704,7 +1712,7 @@ proc evalExpr*(expr: IRExpr, row: Table[string, string], ctx: ExecutionContext =
return "" return ""
of irekSubquery: of irekSubquery:
# Execute subquery and return scalar value (first column of first row) # Execute subquery and return scalar value (first column of first row)
if ctx != nil: if ctx != nil and expr.subqueryPlan != nil:
let savedOuter = ctx.outerRow let savedOuter = ctx.outerRow
let savedPlan = ctx.subqueryPlan let savedPlan = ctx.subqueryPlan
ctx.outerRow = row ctx.outerRow = row
@@ -2722,23 +2730,32 @@ proc lowerSelect*(node: Node): IRPlan =
projectPlan.projectSource = result projectPlan.projectSource = result
projectPlan.projectExprs = @[] projectPlan.projectExprs = @[]
projectPlan.projectAliases = @[] projectPlan.projectAliases = @[]
var seenAliases = initTable[string, int]()
for i, e in node.selResult: for i, e in node.selResult:
projectPlan.projectExprs.add(lowerExpr(e)) projectPlan.projectExprs.add(lowerExpr(e))
var alias = ""
if e.exprAlias.len > 0: if e.exprAlias.len > 0:
projectPlan.projectAliases.add(e.exprAlias) alias = e.exprAlias
elif e.kind == nkIdent: elif e.kind == nkIdent:
projectPlan.projectAliases.add(e.identName) alias = e.identName
elif e.kind == nkPath and e.pathParts.len > 0: elif e.kind == nkPath and e.pathParts.len > 0:
projectPlan.projectAliases.add(e.pathParts.join(".")) alias = e.pathParts.join(".")
elif e.kind == nkFuncCall: elif e.kind == nkFuncCall:
var aliasArgs: seq[string] = @[] var aliasArgs: seq[string] = @[]
for arg in e.funcArgs: for arg in e.funcArgs:
aliasArgs.add(exprToSql(arg)) aliasArgs.add(exprToSql(arg))
projectPlan.projectAliases.add(e.funcName & "(" & aliasArgs.join(", ") & ")") alias = e.funcName & "(" & aliasArgs.join(", ") & ")"
elif e.kind == nkStar: elif e.kind == nkStar:
projectPlan.projectAliases.add("*") alias = "*"
else: else:
projectPlan.projectAliases.add("col" & $i) alias = "col" & $i
# Deduplicate aliases
if alias in seenAliases:
seenAliases[alias] += 1
alias = alias & "_" & $seenAliases[alias]
else:
seenAliases[alias] = 0
projectPlan.projectAliases.add(alias)
result = projectPlan result = projectPlan
if node.selLimit != nil or node.selOffset != nil: if node.selLimit != nil or node.selOffset != nil:
@@ -3040,14 +3057,14 @@ proc executePlan*(ctx: ExecutionContext, plan: IRPlan): seq[Row] =
var minVal = "" var minVal = ""
for row in filteredRows: for row in filteredRows:
let v = evalExpr(expr.aggArgs[0], row, ctx) let v = evalExpr(expr.aggArgs[0], row, ctx)
if v == "\\\\N": continue if v == "\\N": continue
if minVal == "" or cmpMin(v, minVal): minVal = v if minVal == "" or cmpMin(v, minVal): minVal = v
newRow[alias] = minVal newRow[alias] = minVal
of irMax: of irMax:
var maxVal = "" var maxVal = ""
for row in filteredRows: for row in filteredRows:
let v = evalExpr(expr.aggArgs[0], row, ctx) let v = evalExpr(expr.aggArgs[0], row, ctx)
if v == "\\\\N": continue if v == "\\N": continue
if maxVal == "" or cmpMax(v, maxVal): maxVal = v if maxVal == "" or cmpMax(v, maxVal): maxVal = v
newRow[alias] = maxVal newRow[alias] = maxVal
of irArrayAgg: of irArrayAgg:
@@ -3220,14 +3237,14 @@ proc executePlan*(ctx: ExecutionContext, plan: IRPlan): seq[Row] =
var minVal = "" var minVal = ""
for row in filteredRows: for row in filteredRows:
let v = evalExpr(aggExpr.aggArgs[0], row, ctx) let v = evalExpr(aggExpr.aggArgs[0], row, ctx)
if v == "\\\\N": continue if v == "\\N": continue
if minVal == "" or cmpMin(v, minVal): minVal = v if minVal == "" or cmpMin(v, minVal): minVal = v
aggRow[aggKey] = minVal aggRow[aggKey] = minVal
of irMax: of irMax:
var maxVal = "" var maxVal = ""
for row in filteredRows: for row in filteredRows:
let v = evalExpr(aggExpr.aggArgs[0], row, ctx) let v = evalExpr(aggExpr.aggArgs[0], row, ctx)
if v == "\\\\N": continue if v == "\\N": continue
if maxVal == "" or cmpMax(v, maxVal): maxVal = v if maxVal == "" or cmpMax(v, maxVal): maxVal = v
aggRow[aggKey] = maxVal aggRow[aggKey] = maxVal
of irArrayAgg: of irArrayAgg:
@@ -3733,14 +3750,14 @@ proc executePlan*(ctx: ExecutionContext, plan: IRPlan): seq[Row] =
var minVal = "" var minVal = ""
for row in matchingRows: for row in matchingRows:
let v = evalExpr(plan.pivotAgg.aggArgs[0], row, ctx) let v = evalExpr(plan.pivotAgg.aggArgs[0], row, ctx)
if v == "\\\\N": continue if v == "\\N": continue
if minVal == "" or cmpMin(v, minVal): minVal = v if minVal == "" or cmpMin(v, minVal): minVal = v
aggResult = minVal aggResult = minVal
of irMax: of irMax:
var maxVal = "" var maxVal = ""
for row in matchingRows: for row in matchingRows:
let v = evalExpr(plan.pivotAgg.aggArgs[0], row, ctx) let v = evalExpr(plan.pivotAgg.aggArgs[0], row, ctx)
if v == "\\\\N": continue if v == "\\N": continue
if maxVal == "" or cmpMax(v, maxVal): maxVal = v if maxVal == "" or cmpMax(v, maxVal): maxVal = v
aggResult = maxVal aggResult = maxVal
else: discard else: discard
@@ -4039,22 +4056,30 @@ proc bindParams*(node: Node, params: seq[WireValue]): Node =
proc getSelectColumns(stmt: Node): seq[string] = proc getSelectColumns(stmt: Node): seq[string] =
result = @[] result = @[]
if stmt.kind != nkSelect: return result if stmt.kind != nkSelect: return result
var seenAliases = initTable[string, int]()
for i, e in stmt.selResult: for i, e in stmt.selResult:
var alias = ""
if e.exprAlias.len > 0: if e.exprAlias.len > 0:
result.add(e.exprAlias) alias = e.exprAlias
elif e.kind == nkIdent: elif e.kind == nkIdent:
result.add(e.identName) alias = e.identName
elif e.kind == nkPath and e.pathParts.len > 0: elif e.kind == nkPath and e.pathParts.len > 0:
result.add(e.pathParts.join(".")) alias = e.pathParts.join(".")
elif e.kind == nkFuncCall: elif e.kind == nkFuncCall:
var aliasArgs: seq[string] = @[] var aliasArgs: seq[string] = @[]
for arg in e.funcArgs: for arg in e.funcArgs:
aliasArgs.add(exprToSql(arg)) aliasArgs.add(exprToSql(arg))
result.add(e.funcName & "(" & aliasArgs.join(", ") & ")") alias = e.funcName & "(" & aliasArgs.join(", ") & ")"
elif e.kind == nkStar: elif e.kind == nkStar:
result.add("*") alias = "*"
else: else:
result.add("col" & $i) alias = "col" & $i
if alias in seenAliases:
seenAliases[alias] += 1
alias = alias & "_" & $seenAliases[alias]
else:
seenAliases[alias] = 0
result.add(alias)
proc isDDL(stmt: Node): bool = proc isDDL(stmt: Node): bool =
case stmt.kind case stmt.kind
@@ -4065,6 +4090,7 @@ proc isDDL(stmt: Node): bool =
nkCreateUser, nkDropUser, nkCreateUser, nkDropUser,
nkCreatePolicy, nkDropPolicy, nkCreatePolicy, nkDropPolicy,
nkCreateGraph, nkDropGraph, nkCreateGraph, nkDropGraph,
nkCreateDatabase, nkDropDatabase,
nkGrant, nkRevoke, nkGrant, nkRevoke,
nkEnableRLS, nkDisableRLS: nkEnableRLS, nkDisableRLS:
result = true result = true
@@ -4907,7 +4933,7 @@ proc executeQueryImpl(ctx: ExecutionContext, astNode: Node, params: seq[WireValu
if ctx.pendingTxn != nil and ctx.pendingTxn.state == tsActive: if ctx.pendingTxn != nil and ctx.pendingTxn.state == tsActive:
var kvPairs: seq[(string, seq[byte])] var kvPairs: seq[(string, seq[byte])]
for key, version in ctx.pendingTxn.writeSet: for key, version in ctx.pendingTxn.writeSet:
if version.value == @[] and version.xmax != TxnId(0): if version.isDelete:
ctx.db.delete(key) ctx.db.delete(key)
else: else:
ctx.db.put(key, version.value) ctx.db.put(key, version.value)
@@ -5333,6 +5359,80 @@ proc executeQueryImpl(ctx: ExecutionContext, astNode: Node, params: seq[WireValu
ctx.sessionVars[stmt.svName] = stmt.svValue ctx.sessionVars[stmt.svName] = stmt.svValue
return okResult(msg="SET " & stmt.svName & " = " & stmt.svValue) return okResult(msg="SET " & stmt.svName & " = " & stmt.svValue)
of nkCreateDatabase:
if ctx.registry == nil:
return errResult("Multi-database support not enabled")
if not isValidDbName(stmt.cdDbName):
return errResult("Invalid database name: " & stmt.cdDbName)
if databaseExists(ctx.registry, stmt.cdDbName) and not stmt.cdIfNotExists:
return errResult("Database already exists: " & stmt.cdDbName)
if databaseExists(ctx.registry, stmt.cdDbName) and stmt.cdIfNotExists:
return okResult(msg="CREATE DATABASE " & stmt.cdDbName)
try:
discard getOrCreateDatabase(ctx.registry, stmt.cdDbName)
let dbKey = "_schema:databases:" & stmt.cdDbName
ctx.db.put(dbKey, cast[seq[byte]]("created"))
return okResult(msg="CREATE DATABASE " & stmt.cdDbName)
except CatchableError as e:
return errResult("CREATE DATABASE failed: " & e.msg)
of nkDropDatabase:
if ctx.registry == nil:
return errResult("Multi-database support not enabled")
if stmt.ddDbName == "default":
return errResult("Cannot drop the default database")
try:
let count = getConnectionCount(ctx.registry, stmt.ddDbName)
if count > 0:
return errResult("Cannot drop database '" & stmt.ddDbName &
"': " & $count & " active connections")
let dbKey = "_schema:databases:" & stmt.ddDbName
ctx.db.delete(dbKey)
if dropDatabase(ctx.registry, stmt.ddDbName):
return okResult(msg="DROP DATABASE " & stmt.ddDbName)
elif stmt.ddIfExists:
return okResult(msg="DROP DATABASE " & stmt.ddDbName)
else:
return errResult("Database not found: " & stmt.ddDbName)
except CatchableError as e:
return errResult("DROP DATABASE failed: " & e.msg)
of nkUseDatabase:
if ctx.registry == nil:
return errResult("Multi-database support not enabled")
if ctx.pendingTxn != nil:
return errResult("Cannot switch database inside a transaction. Commit or rollback first.")
let info = getDatabaseInfo(ctx.registry, stmt.udDbName)
if info == nil:
return errResult("Database not found: " & stmt.udDbName)
let targetCtx = cast[ExecutionContext](cast[pointer](info.ctx))
let oldDb = ctx.currentDatabase
ctx.db = info.db
ctx.tables = targetCtx.tables
ctx.btrees = targetCtx.btrees
ctx.views = targetCtx.views
ctx.ftsIndexes = targetCtx.ftsIndexes
ctx.vectorIndexes = targetCtx.vectorIndexes
ctx.users = targetCtx.users
ctx.policies = targetCtx.policies
ctx.graphs = targetCtx.graphs
ctx.autoIncCounters = targetCtx.autoIncCounters
ctx.sequences = targetCtx.sequences
ctx.currentDatabase = stmt.udDbName
decrementConnections(ctx.registry, oldDb)
incrementConnections(ctx.registry, stmt.udDbName)
return okResult(msg="Changed to database '" & stmt.udDbName & "'")
of nkShowDatabases:
if ctx.registry == nil:
return errResult("Multi-database support not enabled")
var rows: seq[Row] = @[]
for dbName in listDatabases(ctx.registry):
var row = initTable[string, string]()
row["name"] = dbName
rows.add(row)
return okResult(rows, @["name"])
else: else:
return errResult("Unsupported statement type: " & $stmt.kind) return errResult("Unsupported statement type: " & $stmt.kind)
+251
View File
@@ -0,0 +1,251 @@
import std/unittest
import std/strutils
import std/os
import std/tables
import ../src/barabadb/query/[parser, executor, lexer, ast]
import ../src/barabadb/storage/lsm
const testDir = "/tmp/baradb_bugfix_test"
proc setupCtx(): ExecutionContext =
removeDir(testDir)
createDir(testDir)
let db = newLSMTree(testDir)
var ctx = newExecutionContext(db)
# Create tables
discard executeQuery(ctx, parse("""
CREATE TABLE thread (id INTEGER PRIMARY KEY, name TEXT, category INTEGER, isDeleted INTEGER)
"""))
discard executeQuery(ctx, parse("""
CREATE TABLE category (id INTEGER PRIMARY KEY, name TEXT, description TEXT)
"""))
discard executeQuery(ctx, parse("""
CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)
"""))
ctx
proc teardown(ctx: ExecutionContext) =
ctx.db.close()
removeDir(testDir)
suite "Bug fixes — IN list, nkPath exprToSql, multi-table joins":
test "IN (list) parses without error":
let sql = "SELECT id, name FROM users WHERE id IN (1, 2, 3)"
let tokens = tokenize(sql)
let ast = parse(tokens)
check ast.stmts[0].kind == nkSelect
let whereExpr = ast.stmts[0].selWhere.whereExpr
check whereExpr.kind == nkInExpr
check whereExpr.inRight.kind == nkArrayLit
check whereExpr.inRight.arrayElems.len == 3
test "IN (list) with strings parses":
let sql = "SELECT * FROM users WHERE name IN ('alice', 'bob', 'charlie')"
let tokens = tokenize(sql)
let ast = parse(tokens)
let whereExpr = ast.stmts[0].selWhere.whereExpr
check whereExpr.kind == nkInExpr
check whereExpr.inRight.kind == nkArrayLit
check whereExpr.inRight.arrayElems.len == 3
test "IN (subquery) still parses":
let sql = "SELECT id FROM users WHERE id IN (SELECT id FROM admins)"
let tokens = tokenize(sql)
let ast = parse(tokens)
let whereExpr = ast.stmts[0].selWhere.whereExpr
check whereExpr.kind == nkInExpr
check whereExpr.inRight.kind == nkSubquery
test "nkPath column alias in SELECT produces correct data":
let sql = "SELECT t.id FROM posts t"
let tokens = tokenize(sql)
let ast = parse(tokens)
let selExpr = ast.stmts[0].selResult[0]
check selExpr.kind == nkPath
check selExpr.pathParts == @["t", "id"]
# Verify correct data is returned
var ctx = setupCtx()
defer: teardown(ctx)
discard executeQuery(ctx, parse("CREATE TABLE posts (id INTEGER PRIMARY KEY, title TEXT)"))
discard executeQuery(ctx, parse("INSERT INTO posts (id, title) VALUES (42, 'Hello')"))
let r = executeQuery(ctx, parse(sql))
check r.success
check r.rows.len == 1
# Column is "t.id" (qualified by alias)
check r.rows[0]["t.id"] == "42"
test "IN (list) executes with actual data":
var ctx = setupCtx()
defer: teardown(ctx)
discard executeQuery(ctx, parse("INSERT INTO users (id, name) VALUES (1, 'alice')"))
discard executeQuery(ctx, parse("INSERT INTO users (id, name) VALUES (2, 'bob')"))
discard executeQuery(ctx, parse("INSERT INTO users (id, name) VALUES (3, 'charlie')"))
discard executeQuery(ctx, parse("INSERT INTO users (id, name) VALUES (4, 'dave')"))
let r = executeQuery(ctx, parse("SELECT id, name FROM users WHERE id IN (1, 3, 4)"))
check r.success
check r.rows.len == 3
test "Multi-table implicit join without aliases (Bug 1)":
var ctx = setupCtx()
defer: teardown(ctx)
discard executeQuery(ctx, parse("INSERT INTO thread (id, name, category, isDeleted) VALUES (3, 'Test Thread', 1, 0)"))
discard executeQuery(ctx, parse("INSERT INTO category (id, name, description) VALUES (1, 'General', 'General discussion')"))
# This used to return 0 rows
let r = executeQuery(ctx, parse("SELECT thread.id, thread.name, category.id, category.name FROM thread, category WHERE thread.id = 3 AND thread.isDeleted = 0 AND thread.category = category.id"))
check r.success
check r.rows.len == 1
test "Multi-table join with aliases still works":
var ctx = setupCtx()
defer: teardown(ctx)
discard executeQuery(ctx, parse("INSERT INTO thread (id, name, category, isDeleted) VALUES (3, 'Test Thread', 1, 0)"))
discard executeQuery(ctx, parse("INSERT INTO category (id, name, description) VALUES (1, 'General', 'General discussion')"))
let r = executeQuery(ctx, parse("SELECT t.id AS thread_id, t.name AS thread_name, c.id AS cat_id, c.name AS cat_name FROM thread t, category c WHERE t.id = 3 AND t.category = c.id"))
check r.success
check r.rows.len == 1
check r.columns == @["thread_id", "thread_name", "cat_id", "cat_name"]
test "Three-table join without aliases (Bug 4 - nkPath in column names)":
var ctx = setupCtx()
defer: teardown(ctx)
discard executeQuery(ctx, parse("CREATE TABLE post (id INTEGER PRIMARY KEY, author INTEGER, thread INTEGER, creation TEXT)"))
discard executeQuery(ctx, parse("INSERT INTO thread (id, name, category, isDeleted) VALUES (5, 'Thread5', 1, 0)"))
discard executeQuery(ctx, parse("INSERT INTO category (id, name, description) VALUES (1, 'Cat1', 'desc1')"))
discard executeQuery(ctx, parse("INSERT INTO users (id, name) VALUES (10, 'Alice')"))
discard executeQuery(ctx, parse("INSERT INTO post (id, author, thread, creation) VALUES (100, 10, 5, '2024-06-01 12:00:00')"))
# Three-table join with nkPath references
let r = executeQuery(ctx, parse("SELECT post.id, post.creation, post.thread, users.id, users.name FROM post, users, thread WHERE post.thread = thread.id AND post.author = users.id AND post.id = 100"))
check r.success
check r.rows.len == 1
# Verify qualified column references resolve correctly
check r.rows[0]["post.id"] == "100"
check r.rows[0]["users.name"] == "Alice"
check r.rows[0]["post.thread"] == "5"
# Check column names don't contain "nkPath"
for col in r.columns:
check not col.contains("nkPath")
test "NOT IN (list) parses and executes":
var ctx = setupCtx()
defer: teardown(ctx)
discard executeQuery(ctx, parse("INSERT INTO users (id, name) VALUES (1, 'alice')"))
discard executeQuery(ctx, parse("INSERT INTO users (id, name) VALUES (2, 'bob')"))
discard executeQuery(ctx, parse("INSERT INTO users (id, name) VALUES (3, 'charlie')"))
discard executeQuery(ctx, parse("INSERT INTO users (id, name) VALUES (4, 'dave')"))
let r = executeQuery(ctx, parse("SELECT id, name FROM users WHERE id NOT IN (1, 4)"))
check r.success
check r.rows.len == 2
test "NOT LIKE parses and executes":
var ctx = setupCtx()
defer: teardown(ctx)
discard executeQuery(ctx, parse("INSERT INTO users (id, name) VALUES (1, 'alice')"))
discard executeQuery(ctx, parse("INSERT INTO users (id, name) VALUES (2, 'bob')"))
discard executeQuery(ctx, parse("INSERT INTO users (id, name) VALUES (3, 'charlie')"))
let r = executeQuery(ctx, parse("SELECT name FROM users WHERE name NOT LIKE 'a%'"))
check r.success
check r.rows.len == 2
test "NOT BETWEEN parses and executes":
var ctx = setupCtx()
defer: teardown(ctx)
discard executeQuery(ctx, parse("INSERT INTO users (id, name) VALUES (1, 'alice')"))
discard executeQuery(ctx, parse("INSERT INTO users (id, name) VALUES (5, 'bob')"))
discard executeQuery(ctx, parse("INSERT INTO users (id, name) VALUES (10, 'charlie')"))
discard executeQuery(ctx, parse("INSERT INTO users (id, name) VALUES (15, 'dave')"))
let r = executeQuery(ctx, parse("SELECT id, name FROM users WHERE id NOT BETWEEN 3 AND 12"))
check r.success
check r.rows.len == 2
test "Multi-column ORDER BY sorts correctly":
var ctx = setupCtx()
defer: teardown(ctx)
discard executeQuery(ctx, parse("INSERT INTO users (id, name) VALUES (1, 'alice')"))
discard executeQuery(ctx, parse("INSERT INTO users (id, name) VALUES (2, 'bob')"))
discard executeQuery(ctx, parse("INSERT INTO users (id, name) VALUES (3, 'alice')"))
discard executeQuery(ctx, parse("INSERT INTO users (id, name) VALUES (4, 'charlie')"))
let r = executeQuery(ctx, parse("SELECT name, id FROM users ORDER BY name ASC, id DESC"))
check r.success
check r.rows.len == 4
# After sorting by name ASC, then id DESC within same name:
# alice(3), alice(1), bob(2), charlie(4)
check r.rows[0]["id"] == "3"
check r.rows[1]["id"] == "1"
check r.rows[2]["id"] == "2"
check r.rows[3]["id"] == "4"
test "Numeric != comparison is consistent with =":
var ctx = setupCtx()
defer: teardown(ctx)
# 5.0 stored as string should equal integer 5 via numeric comparison
let r = executeQuery(ctx, parse("SELECT id, name FROM users WHERE id != 999"))
check r.success
# Should return all rows since none have id=999
test "Column alias consistency for table.column in getSelectColumns":
var ctx = setupCtx()
defer: teardown(ctx)
discard executeQuery(ctx, parse("CREATE TABLE posts (id INTEGER PRIMARY KEY, title TEXT)"))
discard executeQuery(ctx, parse("INSERT INTO posts (id, title) VALUES (1, 'Hello')"))
let r = executeQuery(ctx, parse("SELECT posts.id, posts.title FROM posts"))
check r.success
check r.rows.len == 1
# Column names should match the full path "posts.id", "posts.title"
check r.columns == @["posts.id", "posts.title"]
# Data should be accessible via both qualified and unqualified names
check r.rows[0]["posts.id"] == "1"
test "DELETE inside transaction actually removes row":
var ctx = setupCtx()
defer: teardown(ctx)
discard executeQuery(ctx, parse("INSERT INTO users (id, name) VALUES (10, 'temp')"))
# Verify row exists
let r1 = executeQuery(ctx, parse("SELECT * FROM users WHERE id = 10"))
check r1.success
check r1.rows.len == 1
# Delete within transaction
discard executeQuery(ctx, parse("BEGIN"))
let del = executeQuery(ctx, parse("DELETE FROM users WHERE id = 10"))
check del.success
discard executeQuery(ctx, parse("COMMIT"))
# Verify row is gone
let r2 = executeQuery(ctx, parse("SELECT * FROM users WHERE id = 10"))
check r2.success
check r2.rows.len == 0
test "PK-only row survives transaction commit":
var ctx = setupCtx()
defer: teardown(ctx)
discard executeQuery(ctx, parse("CREATE TABLE pksimple (id INTEGER PRIMARY KEY)"))
discard executeQuery(ctx, parse("BEGIN"))
discard executeQuery(ctx, parse("INSERT INTO pksimple (id) VALUES (1)"))
discard executeQuery(ctx, parse("COMMIT"))
let r = executeQuery(ctx, parse("SELECT * FROM pksimple WHERE id = 1"))
check r.success
check r.rows.len == 1
test "MIN and MAX skip NULL values":
var ctx = setupCtx()
defer: teardown(ctx)
discard executeQuery(ctx, parse("CREATE TABLE scores (id INTEGER PRIMARY KEY, val INTEGER)"))
discard executeQuery(ctx, parse("INSERT INTO scores (id, val) VALUES (1, 10)"))
discard executeQuery(ctx, parse("INSERT INTO scores (id, val) VALUES (2, NULL)"))
discard executeQuery(ctx, parse("INSERT INTO scores (id, val) VALUES (3, 30)"))
let rmin = executeQuery(ctx, parse("SELECT MIN(val) AS m FROM scores"))
check rmin.success
check rmin.rows.len == 1
check rmin.rows[0]["m"] == "10"
let rmax = executeQuery(ctx, parse("SELECT MAX(val) AS m FROM scores"))
check rmax.success
check rmax.rows.len == 1
check rmax.rows[0]["m"] == "30"