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
xmax*: TxnId # deleting transaction (0 = not deleted)
lsn*: Lsn
isDelete*: bool
Transaction* = ref object
id*: TxnId
@@ -147,7 +148,7 @@ proc read*(tm: TxnManager, txn: Transaction, key: string): (bool, seq[byte]) =
if key in txn.writeSet:
let version = txn.writeSet[key]
release(tm.lock)
if version.value == @[]:
if version.isDelete or version.value == @[]:
return (false, @[])
return (true, version.value)
@@ -230,7 +231,61 @@ proc write*(tm: TxnManager, txn: Transaction, key: string, value: seq[byte]): bo
return true
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)
+123 -23
View File
@@ -24,6 +24,7 @@ import ../storage/wal
import ../core/mvcc
import ../core/tracing
import ../fts/engine as fts
import ../core/registry
proc cmpMax(a, b: string): bool =
var fa, fb: float
@@ -108,6 +109,8 @@ type
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
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
name*: string
@@ -183,7 +186,7 @@ proc errResult*(msg: string): ExecResult =
proc evalNodeToString(node: Node): string
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](),
btrees: initTable[string, BTreeIndex[string, IndexEntry]](),
views: initTable[string, Node](),
@@ -196,7 +199,10 @@ proc newExecutionContext*(db: LSMTree): ExecutionContext =
sessionVars: initTable[string, string](),
autoIncCounters: initTable[string, int64](),
sequences: initTable[string, int64](),
onChange: nil)
txnManager: newTxnManager(),
onChange: nil,
currentDatabase: "default",
registry: registry)
result.sharedLock = SharedLock()
initLock(result.sharedLock.lock)
restoreSchema(result)
@@ -402,7 +408,9 @@ proc cloneForConnection*(ctx: ExecutionContext): ExecutionContext =
sessionVars: svCopy,
autoIncCounters: ctx.autoIncCounters,
sequences: ctx.sequences,
pendingTxn: nil, onChange: ctx.onChange)
pendingTxn: nil, onChange: ctx.onChange,
currentDatabase: ctx.currentDatabase,
registry: ctx.registry)
result.sharedLock = ctx.sharedLock
# ----------------------------------------------------------------------
@@ -1704,7 +1712,7 @@ proc evalExpr*(expr: IRExpr, row: Table[string, string], ctx: ExecutionContext =
return ""
of irekSubquery:
# 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 savedPlan = ctx.subqueryPlan
ctx.outerRow = row
@@ -2722,23 +2730,32 @@ proc lowerSelect*(node: Node): IRPlan =
projectPlan.projectSource = result
projectPlan.projectExprs = @[]
projectPlan.projectAliases = @[]
var seenAliases = initTable[string, int]()
for i, e in node.selResult:
projectPlan.projectExprs.add(lowerExpr(e))
var alias = ""
if e.exprAlias.len > 0:
projectPlan.projectAliases.add(e.exprAlias)
alias = e.exprAlias
elif e.kind == nkIdent:
projectPlan.projectAliases.add(e.identName)
alias = e.identName
elif e.kind == nkPath and e.pathParts.len > 0:
projectPlan.projectAliases.add(e.pathParts.join("."))
alias = e.pathParts.join(".")
elif e.kind == nkFuncCall:
var aliasArgs: seq[string] = @[]
for arg in e.funcArgs:
aliasArgs.add(exprToSql(arg))
projectPlan.projectAliases.add(e.funcName & "(" & aliasArgs.join(", ") & ")")
alias = e.funcName & "(" & aliasArgs.join(", ") & ")"
elif e.kind == nkStar:
projectPlan.projectAliases.add("*")
alias = "*"
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
if node.selLimit != nil or node.selOffset != nil:
@@ -3040,14 +3057,14 @@ proc executePlan*(ctx: ExecutionContext, plan: IRPlan): seq[Row] =
var minVal = ""
for row in filteredRows:
let v = evalExpr(expr.aggArgs[0], row, ctx)
if v == "\\\\N": continue
if v == "\\N": continue
if minVal == "" or cmpMin(v, minVal): minVal = v
newRow[alias] = minVal
of irMax:
var maxVal = ""
for row in filteredRows:
let v = evalExpr(expr.aggArgs[0], row, ctx)
if v == "\\\\N": continue
if v == "\\N": continue
if maxVal == "" or cmpMax(v, maxVal): maxVal = v
newRow[alias] = maxVal
of irArrayAgg:
@@ -3220,14 +3237,14 @@ proc executePlan*(ctx: ExecutionContext, plan: IRPlan): seq[Row] =
var minVal = ""
for row in filteredRows:
let v = evalExpr(aggExpr.aggArgs[0], row, ctx)
if v == "\\\\N": continue
if v == "\\N": continue
if minVal == "" or cmpMin(v, minVal): minVal = v
aggRow[aggKey] = minVal
of irMax:
var maxVal = ""
for row in filteredRows:
let v = evalExpr(aggExpr.aggArgs[0], row, ctx)
if v == "\\\\N": continue
if v == "\\N": continue
if maxVal == "" or cmpMax(v, maxVal): maxVal = v
aggRow[aggKey] = maxVal
of irArrayAgg:
@@ -3733,14 +3750,14 @@ proc executePlan*(ctx: ExecutionContext, plan: IRPlan): seq[Row] =
var minVal = ""
for row in matchingRows:
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
aggResult = minVal
of irMax:
var maxVal = ""
for row in matchingRows:
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
aggResult = maxVal
else: discard
@@ -4039,22 +4056,30 @@ proc bindParams*(node: Node, params: seq[WireValue]): Node =
proc getSelectColumns(stmt: Node): seq[string] =
result = @[]
if stmt.kind != nkSelect: return result
var seenAliases = initTable[string, int]()
for i, e in stmt.selResult:
var alias = ""
if e.exprAlias.len > 0:
result.add(e.exprAlias)
alias = e.exprAlias
elif e.kind == nkIdent:
result.add(e.identName)
alias = e.identName
elif e.kind == nkPath and e.pathParts.len > 0:
result.add(e.pathParts.join("."))
alias = e.pathParts.join(".")
elif e.kind == nkFuncCall:
var aliasArgs: seq[string] = @[]
for arg in e.funcArgs:
aliasArgs.add(exprToSql(arg))
result.add(e.funcName & "(" & aliasArgs.join(", ") & ")")
alias = e.funcName & "(" & aliasArgs.join(", ") & ")"
elif e.kind == nkStar:
result.add("*")
alias = "*"
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 =
case stmt.kind
@@ -4065,6 +4090,7 @@ proc isDDL(stmt: Node): bool =
nkCreateUser, nkDropUser,
nkCreatePolicy, nkDropPolicy,
nkCreateGraph, nkDropGraph,
nkCreateDatabase, nkDropDatabase,
nkGrant, nkRevoke,
nkEnableRLS, nkDisableRLS:
result = true
@@ -4907,7 +4933,7 @@ proc executeQueryImpl(ctx: ExecutionContext, astNode: Node, params: seq[WireValu
if ctx.pendingTxn != nil and ctx.pendingTxn.state == tsActive:
var kvPairs: seq[(string, seq[byte])]
for key, version in ctx.pendingTxn.writeSet:
if version.value == @[] and version.xmax != TxnId(0):
if version.isDelete:
ctx.db.delete(key)
else:
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
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:
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"