feat: Multi-tenant ERP support via session variables + RLS
CI / test (push) Has been cancelled
CI / verify (push) Has been cancelled
Clients CI / build-server (push) Has been cancelled
Clients CI / test-python (push) Has been cancelled
Clients CI / test-javascript (push) Has been cancelled
Clients CI / test-nim (push) Has been cancelled
Clients CI / test-rust (push) Has been cancelled
CI / test (push) Has been cancelled
CI / verify (push) Has been cancelled
Clients CI / build-server (push) Has been cancelled
Clients CI / test-python (push) Has been cancelled
Clients CI / test-javascript (push) Has been cancelled
Clients CI / test-nim (push) Has been cancelled
Clients CI / test-rust (push) Has been cancelled
- SET var = value / current_setting('var') for session-scoped variables
- current_user / current_role SQL keywords with auth bridge
- server.nim + httpserver.nim populate ExecutionContext.currentUser/currentRole
- RLS policies can reference current_setting('app.tenant_id') for tenant isolation
- Fixed evalExpr to propagate ctx recursively (fixes current_user in sub-expressions)
- Fixed GROUPING SETS execution (lowerSelect checks selGroupingSetsKind)
- Fixed FTS CREATE INDEX docId mismatch (hash of tableName.$key)
- Fixed all test suites to use isolated temp directories
- Added 5 multi-tenant tests (355 total, all green)
- Updated docs: PLAN_SQL_ADVANCED.md, baraql.md, changelog.md
This commit is contained in:
+165
-11
@@ -2287,6 +2287,69 @@ suite "Row-Level Security":
|
||||
check res.success
|
||||
check res.rows.len == 1 # superuser sees all (only 1 row exists)
|
||||
|
||||
suite "Session Variables and Multi-Tenant":
|
||||
test "SET statement parse":
|
||||
let ast = parse("SET app.tenant_id = 'company-123'")
|
||||
check ast.stmts.len == 1
|
||||
check ast.stmts[0].kind == nkSetVar
|
||||
check ast.stmts[0].svName == "app.tenant_id"
|
||||
check ast.stmts[0].svValue == "company-123"
|
||||
|
||||
test "SET and current_setting":
|
||||
let tmpDir = getTempDir() / "baradb_session_test_" & $getMonoTime().ticks
|
||||
var db = newLSMTree(tmpDir)
|
||||
var ctx = qexec.newExecutionContext(db)
|
||||
discard qexec.executeQuery(ctx, parse("SET app.tenant_id = 'company-123'"))
|
||||
let r = qexec.executeQuery(ctx, parse("SELECT current_setting('app.tenant_id') AS tenant"))
|
||||
check r.success
|
||||
check r.rows.len == 1
|
||||
check r.rows[0]["tenant"] == "company-123"
|
||||
|
||||
test "current_user in SELECT":
|
||||
let tmpDir = getTempDir() / "baradb_user_test_" & $getMonoTime().ticks
|
||||
var db = newLSMTree(tmpDir)
|
||||
var ctx = qexec.newExecutionContext(db)
|
||||
ctx.currentUser = "alice"
|
||||
let r = qexec.executeQuery(ctx, parse("SELECT current_user AS me"))
|
||||
check r.success
|
||||
check r.rows.len == 1
|
||||
check r.rows[0]["me"] == "alice"
|
||||
|
||||
test "current_role in SELECT":
|
||||
let tmpDir = getTempDir() / "baradb_role_test_" & $getMonoTime().ticks
|
||||
var db = newLSMTree(tmpDir)
|
||||
var ctx = qexec.newExecutionContext(db)
|
||||
ctx.currentRole = "admin"
|
||||
let r = qexec.executeQuery(ctx, parse("SELECT current_role AS role"))
|
||||
check r.success
|
||||
check r.rows.len == 1
|
||||
check r.rows[0]["role"] == "admin"
|
||||
|
||||
test "Multi-tenant RLS with current_setting":
|
||||
let tmpDir = getTempDir() / "baradb_multitenant_test_" & $getMonoTime().ticks
|
||||
var db = newLSMTree(tmpDir)
|
||||
var ctx = qexec.newExecutionContext(db)
|
||||
discard qexec.executeQuery(ctx, parse("CREATE TABLE invoices (id INTEGER, tenant_id TEXT, amount INT)"))
|
||||
discard qexec.executeQuery(ctx, parse("INSERT INTO invoices (id, tenant_id, amount) VALUES (1, 'company-a', 100)"))
|
||||
discard qexec.executeQuery(ctx, parse("INSERT INTO invoices (id, tenant_id, amount) VALUES (2, 'company-b', 200)"))
|
||||
# Set session variable for tenant
|
||||
discard qexec.executeQuery(ctx, parse("SET app.tenant_id = 'company-a'"))
|
||||
# Create policy that uses current_setting
|
||||
ctx.users["app"] = qexec.UserDef(name: "app", passwordHash: "", isSuperuser: false, roles: @[])
|
||||
ctx.currentUser = "app"
|
||||
ctx.policies["invoices"] = @[
|
||||
qexec.PolicyDef(name: "tenant_isolation", tableName: "invoices", command: "SELECT",
|
||||
usingExpr: Node(kind: nkBinOp, binOp: bkEq,
|
||||
binLeft: Node(kind: nkIdent, identName: "tenant_id"),
|
||||
binRight: Node(kind: nkFuncCall, funcName: "current_setting",
|
||||
funcArgs: @[Node(kind: nkStringLit, strVal: "app.tenant_id")])),
|
||||
withCheckExpr: nil)
|
||||
]
|
||||
let r = qexec.executeQuery(ctx, parse("SELECT id, tenant_id FROM invoices"))
|
||||
check r.success
|
||||
check r.rows.len == 1
|
||||
check r.rows[0]["tenant_id"] == "company-a"
|
||||
|
||||
suite "UTF-8 Support":
|
||||
test "Tokenize UTF-8 identifiers":
|
||||
let tokens = lex.tokenize("SELECT имя FROM потребители")
|
||||
@@ -2303,7 +2366,8 @@ suite "UTF-8 Support":
|
||||
check ast.stmts[0].selWhere.whereExpr.binRight.strVal == "София"
|
||||
|
||||
test "Execute query with UTF-8 data":
|
||||
var db = newLSMTree("")
|
||||
let tmpDir = getTempDir() / "baradb_test_" & $getMonoTime().ticks
|
||||
var db = newLSMTree(tmpDir)
|
||||
var ctx = qexec.newExecutionContext(db)
|
||||
discard qexec.executeQuery(ctx, parse("CREATE TABLE потребители (имя TEXT, град TEXT)"))
|
||||
discard qexec.executeQuery(ctx, parse("INSERT INTO потребители (имя, град) VALUES ('Иван', 'София'), ('Мария', 'Пловдив')"))
|
||||
@@ -2315,7 +2379,8 @@ suite "UTF-8 Support":
|
||||
|
||||
suite "B-Tree Range Scan":
|
||||
test "BETWEEN uses index range scan":
|
||||
var db = newLSMTree("")
|
||||
let tmpDir = getTempDir() / "baradb_test_" & $getMonoTime().ticks
|
||||
var db = newLSMTree(tmpDir)
|
||||
var ctx = qexec.newExecutionContext(db)
|
||||
discard qexec.executeQuery(ctx, parse("CREATE TABLE products (id INTEGER, name TEXT)"))
|
||||
discard qexec.executeQuery(ctx, parse("INSERT INTO products (id, name) VALUES (1, 'apple'), (2, 'banana'), (3, 'cherry'), (4, 'date'), (5, 'elderberry')"))
|
||||
@@ -2325,7 +2390,8 @@ suite "B-Tree Range Scan":
|
||||
check res.rows.len == 3
|
||||
|
||||
test "Greater than uses index range scan":
|
||||
var db = newLSMTree("")
|
||||
let tmpDir = getTempDir() / "baradb_test_" & $getMonoTime().ticks
|
||||
var db = newLSMTree(tmpDir)
|
||||
var ctx = qexec.newExecutionContext(db)
|
||||
discard qexec.executeQuery(ctx, parse("CREATE TABLE nums (id INTEGER, val TEXT)"))
|
||||
discard qexec.executeQuery(ctx, parse("INSERT INTO nums (id, val) VALUES (1, '10'), (2, '20'), (3, '30'), (4, '40'), (5, '50')"))
|
||||
@@ -2335,7 +2401,8 @@ suite "B-Tree Range Scan":
|
||||
check res.rows.len == 3
|
||||
|
||||
test "Less than or equal uses index range scan":
|
||||
var db = newLSMTree("")
|
||||
let tmpDir = getTempDir() / "baradb_test_" & $getMonoTime().ticks
|
||||
var db = newLSMTree(tmpDir)
|
||||
var ctx = qexec.newExecutionContext(db)
|
||||
discard qexec.executeQuery(ctx, parse("CREATE TABLE nums2 (id INTEGER, val TEXT)"))
|
||||
discard qexec.executeQuery(ctx, parse("INSERT INTO nums2 (id, val) VALUES (1, '10'), (2, '20'), (3, '30'), (4, '40'), (5, '50')"))
|
||||
@@ -2458,13 +2525,18 @@ suite "Enhanced Migrations":
|
||||
suite "Parameterized queries":
|
||||
var db: LSMTree
|
||||
var ctx: qexec.ExecutionContext
|
||||
var tmpDir: string
|
||||
var paramInitialized = false
|
||||
|
||||
setup:
|
||||
db = newLSMTree("")
|
||||
ctx = qexec.newExecutionContext(db)
|
||||
discard qexec.executeQuery(ctx, parse("CREATE TABLE users (id INT, name TEXT, age INT)"))
|
||||
discard qexec.executeQuery(ctx, parse("INSERT INTO users (id, name, age) VALUES (1, 'Alice', 30)"))
|
||||
discard qexec.executeQuery(ctx, parse("INSERT INTO users (id, name, age) VALUES (2, 'Bob', 25)"))
|
||||
if not paramInitialized:
|
||||
tmpDir = getTempDir() / "baradb_param_test_" & $getMonoTime().ticks
|
||||
db = newLSMTree(tmpDir)
|
||||
ctx = qexec.newExecutionContext(db)
|
||||
discard qexec.executeQuery(ctx, parse("CREATE TABLE users (id INT, name TEXT, age INT, active BOOLEAN)"))
|
||||
discard qexec.executeQuery(ctx, parse("INSERT INTO users (id, name, age, active) VALUES (1, 'Alice', 30, true)"))
|
||||
discard qexec.executeQuery(ctx, parse("INSERT INTO users (id, name, age, active) VALUES (2, 'Bob', 25, false)"))
|
||||
paramInitialized = true
|
||||
|
||||
test "SELECT with placeholder params":
|
||||
let sql = "SELECT * FROM users WHERE id = ?"
|
||||
@@ -2583,6 +2655,34 @@ suite "Parameterized queries":
|
||||
check r.success
|
||||
check r.rows.len >= 1
|
||||
|
||||
test "JSON contains @>":
|
||||
discard qexec.executeQuery(ctx, parse("CREATE TABLE IF NOT EXISTS jsontest2 (id INT PRIMARY KEY, data JSON)"))
|
||||
discard qexec.executeQuery(ctx, parse("INSERT INTO jsontest2 (id, data) VALUES (1, '{\"name\": \"Alice\", \"age\": 30}')"))
|
||||
let r = qexec.executeQuery(ctx, parse("SELECT id FROM jsontest2 WHERE data @> '{\"name\": \"Alice\"}'"))
|
||||
check r.success
|
||||
check r.rows.len == 1
|
||||
check r.rows[0]["id"] == "1"
|
||||
|
||||
test "JSON contained by <@":
|
||||
let r = qexec.executeQuery(ctx, parse("SELECT id FROM jsontest2 WHERE '{\"name\": \"Alice\"}' <@ data"))
|
||||
check r.success
|
||||
check r.rows.len == 1
|
||||
|
||||
test "JSON has key json_has_key":
|
||||
let r = qexec.executeQuery(ctx, parse("SELECT id FROM jsontest2 WHERE json_has_key(data, 'name') = 'true'"))
|
||||
check r.success
|
||||
check r.rows.len == 1
|
||||
|
||||
test "JSON has any key ?|":
|
||||
let r = qexec.executeQuery(ctx, parse("SELECT id FROM jsontest2 WHERE data ?| '[\"name\", \"missing\"]'"))
|
||||
check r.success
|
||||
check r.rows.len == 1
|
||||
|
||||
test "JSON has all keys ?&":
|
||||
let r = qexec.executeQuery(ctx, parse("SELECT id FROM jsontest2 WHERE data ?& '[\"name\", \"age\"]'"))
|
||||
check r.success
|
||||
check r.rows.len == 1
|
||||
|
||||
test "FTS match operator @@ parse":
|
||||
let ast = parse("SELECT * FROM docs WHERE content @@ 'hello'")
|
||||
check ast.stmts[0].kind == nkSelect
|
||||
@@ -2630,9 +2730,11 @@ suite "Parameterized queries":
|
||||
suite "Window Functions":
|
||||
var db: LSMTree
|
||||
var ctx: qexec.ExecutionContext
|
||||
var tmpDir: string
|
||||
|
||||
setup:
|
||||
db = newLSMTree("")
|
||||
tmpDir = getTempDir() / "baradb_window_test_" & $getMonoTime().ticks
|
||||
db = newLSMTree(tmpDir)
|
||||
ctx = qexec.newExecutionContext(db)
|
||||
discard qexec.executeQuery(ctx, parse("CREATE TABLE employees (id INT, name TEXT, department TEXT, salary INT)"))
|
||||
discard qexec.executeQuery(ctx, parse("INSERT INTO employees (id, name, department, salary) VALUES (1, 'Alice', 'Engineering', 90000)"))
|
||||
@@ -2641,6 +2743,9 @@ suite "Window Functions":
|
||||
discard qexec.executeQuery(ctx, parse("INSERT INTO employees (id, name, department, salary) VALUES (4, 'Diana', 'Sales', 75000)"))
|
||||
discard qexec.executeQuery(ctx, parse("INSERT INTO employees (id, name, department, salary) VALUES (5, 'Eve', 'Engineering', 95000)"))
|
||||
|
||||
teardown:
|
||||
removeDir(tmpDir)
|
||||
|
||||
test "ROW_NUMBER with PARTITION BY and ORDER BY":
|
||||
let r = qexec.executeQuery(ctx, parse("SELECT name, department, ROW_NUMBER() OVER (PARTITION BY department ORDER BY salary DESC) AS rn FROM employees"))
|
||||
check r.success
|
||||
@@ -2692,12 +2797,38 @@ suite "Window Functions":
|
||||
for row in r.rows:
|
||||
check row["bucket"] in @["1", "2"]
|
||||
|
||||
test "FIRST_VALUE with frame":
|
||||
let r = qexec.executeQuery(ctx, parse("SELECT name, salary, FIRST_VALUE(salary) OVER (ORDER BY salary ROWS BETWEEN 1 PRECEDING AND CURRENT ROW) AS first_sal FROM employees"))
|
||||
check r.success
|
||||
check r.rows.len == 5
|
||||
for row in r.rows:
|
||||
if row["name"] == "Charlie":
|
||||
check row["first_sal"] == "70000"
|
||||
if row["name"] == "Diana":
|
||||
check row["first_sal"] == "70000"
|
||||
if row["name"] == "Bob":
|
||||
check row["first_sal"] == "75000"
|
||||
|
||||
test "LAST_VALUE with frame":
|
||||
let r = qexec.executeQuery(ctx, parse("SELECT name, salary, LAST_VALUE(salary) OVER (ORDER BY salary ROWS BETWEEN CURRENT ROW AND 1 FOLLOWING) AS last_sal FROM employees"))
|
||||
check r.success
|
||||
check r.rows.len == 5
|
||||
for row in r.rows:
|
||||
if row["name"] == "Charlie":
|
||||
check row["last_sal"] == "75000"
|
||||
if row["name"] == "Diana":
|
||||
check row["last_sal"] == "80000"
|
||||
if row["name"] == "Bob":
|
||||
check row["last_sal"] == "90000"
|
||||
|
||||
suite "GROUP BY Aggregates":
|
||||
var db: LSMTree
|
||||
var ctx: qexec.ExecutionContext
|
||||
var tmpDir: string
|
||||
|
||||
setup:
|
||||
db = newLSMTree("")
|
||||
tmpDir = getTempDir() / "baradb_group_test_" & $getMonoTime().ticks
|
||||
db = newLSMTree(tmpDir)
|
||||
ctx = qexec.newExecutionContext(db)
|
||||
discard qexec.executeQuery(ctx, parse("CREATE TABLE sales (id INT, dept TEXT, amount INT)"))
|
||||
discard qexec.executeQuery(ctx, parse("INSERT INTO sales (id, dept, amount) VALUES (1, 'A', 100)"))
|
||||
@@ -2706,6 +2837,9 @@ suite "GROUP BY Aggregates":
|
||||
discard qexec.executeQuery(ctx, parse("INSERT INTO sales (id, dept, amount) VALUES (4, 'B', 50)"))
|
||||
discard qexec.executeQuery(ctx, parse("INSERT INTO sales (id, dept, amount) VALUES (5, 'C', 300)"))
|
||||
|
||||
teardown:
|
||||
removeDir(tmpDir)
|
||||
|
||||
test "GROUP BY with COUNT(*)":
|
||||
let r = qexec.executeQuery(ctx, parse("SELECT dept, COUNT(*) AS cnt FROM sales GROUP BY dept"))
|
||||
check r.success
|
||||
@@ -2808,6 +2942,26 @@ suite "GROUP BY Aggregates":
|
||||
check r.success
|
||||
check r.rows.len == 4 # one row per employee
|
||||
|
||||
test "CUBE execution":
|
||||
let r = qexec.executeQuery(ctx, parse("SELECT dept, SUM(amount) AS total FROM sales GROUP BY CUBE (dept)"))
|
||||
check r.success
|
||||
# 3 dept groups + 1 grand total = 4 rows
|
||||
check r.rows.len == 4
|
||||
|
||||
test "GROUPING SETS execution":
|
||||
let r = qexec.executeQuery(ctx, parse("SELECT dept, SUM(amount) AS total FROM sales GROUP BY GROUPING SETS ((dept), ())"))
|
||||
check r.success
|
||||
# 3 dept groups + 1 grand total = 4 rows
|
||||
check r.rows.len == 4
|
||||
|
||||
test "UNPIVOT execution":
|
||||
discard qexec.executeQuery(ctx, parse("CREATE TABLE pivoted (name TEXT, eng_salary INT, sales_salary INT)"))
|
||||
discard qexec.executeQuery(ctx, parse("INSERT INTO pivoted (name, eng_salary, sales_salary) VALUES ('Alice', 90000, 0)"))
|
||||
discard qexec.executeQuery(ctx, parse("INSERT INTO pivoted (name, eng_salary, sales_salary) VALUES ('Bob', 0, 70000)"))
|
||||
let r = qexec.executeQuery(ctx, parse("SELECT * FROM pivoted UNPIVOT (salary FOR dept IN (eng_salary, sales_salary))"))
|
||||
check r.success
|
||||
check r.rows.len == 4
|
||||
|
||||
# JOIN tests
|
||||
include "join_tests"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user