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:
@@ -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
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
Reference in New Issue
Block a user