diff --git a/src/barabadb/core/mvcc.nim b/src/barabadb/core/mvcc.nim index 42239c6..773deaf 100644 --- a/src/barabadb/core/mvcc.nim +++ b/src/barabadb/core/mvcc.nim @@ -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) diff --git a/src/barabadb/query/executor.nim b/src/barabadb/query/executor.nim index d121a97..52f295d 100644 --- a/src/barabadb/query/executor.nim +++ b/src/barabadb/query/executor.nim @@ -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) diff --git a/tests/bugfix_test.nim b/tests/bugfix_test.nim new file mode 100644 index 0000000..ddde124 --- /dev/null +++ b/tests/bugfix_test.nim @@ -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"