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
+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"