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

- 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:
2026-05-14 16:28:41 +03:00
parent b0978812cb
commit f7d4961125
13 changed files with 642 additions and 102 deletions
+2
View File
@@ -38,3 +38,5 @@ clients/rust/target/
clients/nim/src/baradb/client
src/barabadb/storage/compaction
docker-compose.test.yml
src/barabadb/query/executor
tests/join_tests
+56 -1
View File
@@ -3,6 +3,8 @@
> **Визия**: BaraDB е самостоятелен, универсален SQL engine с Nim ядро, поддържащ модерни SQL:2023 разширения — Property Graph, Vector Search, JSON документи и прозоречни функции, в една вградена или клиент/сървър конфигурация.
>
> **Принцип**: Само основи. Не се добавят нови светове — само стабилизираме и документираме съществуващите.
>
> **Multi-Tenant фокус**: BaraDB е проектирана да поддържа ERP сценарии с много фирми (tenants) в една база данни. Всеки tenant се изолира чрез Row-Level Security (RLS) + session variables (`SET app.tenant_id = 'X'`), а не чрез отделни бази.
---
@@ -10,7 +12,7 @@
- **Фаза 1 (Base SQL + MVCC + Raft)**: BaraDB core engine
- **Фаза 2 (Advanced SQL)**: Разработена с **Xiaomi Mimo** (`mimo-v2.5-pro`) — Window Functions, MERGE, LATERAL JOIN, Advanced Aggregates, PIVOT/UNPIVOT, SQL/PGQ Property Graph
- **Фаза 3 (Stabilization)**: Текуща — Vector SQL Integration, тестове, документация
- **Фаза 3 (Stabilization + Multi-Tenant)**: Текуща — Vector SQL Integration, Session Variables, `current_user`/`current_role`, RLS tenant isolation, тестове, документация
---
@@ -167,6 +169,49 @@ SELECT * FROM GRAPH_TABLE(org_chart
---
## Част 1.5: Multi-Tenant ERP Support ✅ ГОТОВО
BaraDB поддържа multi-tenant архитектура, при която множество фирми (tenants) работят в една физическа база данни. Това е критично за ERP сценарии, където поддръжката на "сто бази" не е опция.
### Механизъм
| Компонент | Описание |
|-----------|----------|
| **Session Variables** | `SET app.tenant_id = 'company-123'` — задава tenant за текущата сесия |
| **current_setting()** | `current_setting('app.tenant_id')` — чете session променлива в SQL израз |
| **current_user** | `current_user` — връща автентикирания потребител от JWT/SCRAM |
| **current_role** | `current_role` — връща ролята на автентикирания потребител |
| **RLS Policies** | `CREATE POLICY tenant_isolation ON invoices FOR SELECT USING (tenant_id = current_setting('app.tenant_id'))` |
| **Auth Bridge** | `server.nim` и `httpserver.nim` попълват `ExecutionContext.currentUser`/`currentRole` след верификация |
### Пример
```sql
-- Една таблица за всички фирми
CREATE TABLE invoices (
id SERIAL PRIMARY KEY,
tenant_id TEXT NOT NULL,
data JSONB
);
-- Изолация чрез RLS
CREATE POLICY tenant_isolation ON invoices
FOR SELECT USING (tenant_id = current_setting('app.tenant_id'));
-- Всяка сесия вижда само своя tenant
SET app.tenant_id = 'company-a';
SELECT * FROM invoices; -- → само фактури на company-a
```
### Архитектурни предимства
- **JSONB документи** — schema-flexible, лесно се добавят нови полета без миграции (като ArangoDB)
- **RLS изолация** — базата данни гарантира, че всеки tenant вижда само своите данни
- **Един instance** — един BaraDB сървър обслужва всички tenants, вместо сто отделни бази
- **Auth integration** — JWT/SCRAM токените носят `sub` (user) и `role`, които се пропагират до executor-а
---
## Част 2: Мултимодални Възможности (Core Only)
### 2.1 JSON / JSONB Документи ✅ ГОТОВО
@@ -290,6 +335,16 @@ LIMIT 10;
- **MERGE тестове** — поправени чрез изолиране на тестовата директория (unique temp dir per suite)
- **Row storage escape** — `escapeRowVal()` в `execInsert` за стойности със запетай (vector literals)
- **ORDER BY + projection** — `irpkSort` сега е преди `irpkProject` в `lowerSelect`, което позволява `ORDER BY` по колони извън `SELECT`
- **GROUPING SETS execution** — `lowerSelect` сега проверява `selGroupingSetsKind != gskNone` освен `selGroupBy.len > 0`, което позволява изпълнение на GROUPING SETS без традиционен GROUP BY
- **FTS CREATE INDEX docId** — поправено несъответствие в изчислението на `docId` при `CREATE INDEX ... USING FTS` (сега използва хеш на `tableName.$key`, съвместим с DML операциите)
- **Тестова изолация (всички suite-ове)** — всички `newLSMTree("")` заменени с уникални temp директории; setup/teardown за suite-ове с изолирана state
- **Multi-tenant ERP support** — имплементирани критични градивни елементи:
- `SET var = value` — session variables за tenant isolation
- `current_setting('var')` — четене на session променливи в SQL изрази
- `current_user` / `current_role` — SQL keywords, които се оценяват от `ExecutionContext`
- Auth bridge — `server.nim` и `httpserver.nim` попълват `currentUser`/`currentRole` след JWT/SCRAM верификация
- RLS tenant isolation тест — `CREATE POLICY` + `current_setting('app.tenant_id')` работи за multi-tenant филтрация
- `evalExpr` вече предава `ctx` рекурсивно — поправен бъг, при който `current_user`/`current_setting` връщаха празни стойности в под-изрази
---
+40 -1
View File
@@ -602,6 +602,44 @@ SUM(salary) OVER (
)
```
## Multi-Tenant ERP
BaraDB supports running multiple companies (tenants) in a single database instance, using **Row-Level Security (RLS)** combined with **session variables**.
### Session Variables
```sql
SET app.tenant_id = 'company-123';
SELECT current_setting('app.tenant_id') AS tenant;
```
### Current User / Role
```sql
SELECT current_user AS me, current_role AS my_role;
```
### RLS Tenant Isolation
```sql
-- Enable RLS on a table
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
-- Create a policy that filters by tenant
CREATE POLICY tenant_isolation ON invoices
FOR SELECT USING (tenant_id = current_setting('app.tenant_id'));
-- Each session only sees its own data
SET app.tenant_id = 'company-a';
SELECT * FROM invoices; -- only company-a rows
```
### Why Multi-Tenant?
- **One instance, many tenants** — no need to run 100 separate databases
- **JSONB documents** — schema-flexible storage, easy to add fields per tenant
- **RLS guarantees isolation** — the database enforces tenant boundaries, not just the application
## Supported Keywords
| Category | Keywords |
@@ -620,6 +658,7 @@ SUM(salary) OVER (
| JSON | ->, ->> |
| FTS | @@ (BM25 match) |
| Recovery | RECOVER TO TIMESTAMP |
| Functions | count, sum, avg, min, max, stddev, variance, abs, sqrt, lower, upper, len, trim, substr, now, last_insert_id |
| Functions | count, sum, avg, min, max, stddev, variance, abs, sqrt, lower, upper, len, trim, substr, now, last_insert_id, current_setting |
| Session | SET, current_setting, current_user, current_role |
| Window | OVER, PARTITION BY, ROWS, RANGE, UNBOUNDED PRECEDING, CURRENT ROW, FOLLOWING |
| Window Functions | ROW_NUMBER, RANK, DENSE_RANK, LEAD, LAG, FIRST_VALUE, LAST_VALUE, NTILE |
+20
View File
@@ -2,6 +2,26 @@
All notable changes to BaraDB are documented in this file.
## [Unreleased] — SQL:2023 Stabilization
### Fixed
- **GROUPING SETS execution** — `lowerSelect` now creates `irpkGroupBy` when `selGroupingSetsKind != gskNone`, even if `selGroupBy` is empty. Previously, queries like `GROUP BY GROUPING SETS ((dept), ())` bypassed the grouping executor entirely.
- **FTS CREATE INDEX docId mismatch** — `CREATE INDEX ... USING FTS` now computes `docId` as a hash of `tableName.$key`, consistent with DML operations (`INSERT`/`UPDATE`/`DELETE`). Previously, index creation used sequential IDs (0, 1, 2...), causing `@@` queries to never match indexed documents.
- **Test isolation (all suites)** — All `newLSMTree("")` calls replaced with unique temporary directories per suite. Eliminates WAL accumulation issues and flaky tests caused by shared database state between test runs.
- **Window frame parser** — `parseFrameBoundary` no longer consumes `tkRow` after `tkCurrent` incorrectly (was using `tkRows`). Also fixed `tkRow` keyword conflict with `ENABLE ROW LEVEL SECURITY` parsing.
- **ORDER BY + SELECT projection** — `lowerSelect` now places `irpkSort` before `irpkProject`, enabling `ORDER BY` on columns not present in the `SELECT` list.
- **UNPIVOT execution** — Verified and fixed missing test coverage for UNPIVOT transformation.
### Added
- **JSON operators** — `@>` (contains), `<@` (contained by), `?` (has key), `?|` (has any), `?&` (has all) now supported in lexer, parser, and executor.
- **Window frame execution** — `ROWS BETWEEN X PRECEDING AND Y FOLLOWING` / `CURRENT ROW` frame boundaries now respected by `FIRST_VALUE` and `LAST_VALUE`.
- **Session variables** — `SET var_name = value` and `current_setting('var_name')` for connection-scoped key/value storage.
- **Current user/role** — `current_user` and `current_role` SQL keywords evaluate to the authenticated session's user and role.
- **Auth-executor bridge** — Wire server and HTTP server now populate `ExecutionContext.currentUser` and `ExecutionContext.currentRole` after JWT/SCRAM authentication.
- **Multi-tenant RLS** — Row-Level Security policies can now reference `current_user`, `current_role`, and `current_setting('app.tenant_id')` for per-tenant data isolation.
## [1.1.0] — 2026-05-13
### Added
+7 -1
View File
@@ -108,6 +108,8 @@ proc queryHandler(server: HttpServer): RequestHandler =
let ctx = newContext(request)
server.metrics.queriesTotal += 1
var userId = ""
var role = ""
# Auth check
if server.config.authEnabled:
let authHeader = request.headers["Authorization"]
@@ -115,10 +117,12 @@ proc queryHandler(server: HttpServer): RequestHandler =
ctx.json(%*{"error": "Unauthorized"}, 401)
return
let tokenStr = authHeader[7..^1]
let (valid, userId, role) = server.verifyToken(tokenStr)
let (valid, uid, r) = server.verifyToken(tokenStr)
if not valid:
ctx.json(%*{"error": "Unauthorized"}, 401)
return
userId = uid
role = r
let body = parseJson(request.body)
if body == nil or "query" notin body:
@@ -131,6 +135,8 @@ proc queryHandler(server: HttpServer): RequestHandler =
return
var reqCtx = cloneForConnection(server.ctx)
reqCtx.currentUser = userId
reqCtx.currentRole = role
let tokens = tokenize(queryStr)
let astNode = parse(tokens)
+3 -1
View File
@@ -436,9 +436,11 @@ proc handleClient(server: Server, client: AsyncSocket, clientId: int) {.async.}
case header.kind
of mkAuth:
let tokenStr = parseAuthMessage(cast[seq[byte]](payload))
let (valid, userId, _) = verifyToken(secret, tokenStr)
let (valid, userId, role) = verifyToken(secret, tokenStr)
if valid:
authenticated = true
connCtx.currentUser = userId
connCtx.currentRole = role
let okMsg = makeAuthOkMessage(header.requestId)
await client.send(cast[string](okMsg))
info("Client " & $clientId & " authenticated as " & userId)
+14
View File
@@ -40,6 +40,7 @@ type
nkDisableRLS
nkGrant
nkRevoke
nkSetVar
# Clauses
nkFrom
@@ -78,6 +79,8 @@ type
nkIsExpr
nkStar
nkPlaceholder
nkCurrentUser
nkCurrentRole
# Graph-specific
nkGraphTraversal
@@ -144,6 +147,10 @@ type
bkJsonPathText = "->>"
bkFtsMatch = "@@"
bkDistance = "<->"
bkJsonContains = "@>"
bkJsonContainedBy = "<@"
bkJsonHasAny = "?|"
bkJsonHasAll = "?&"
UnaryOpKind* = enum
ukNeg = "-"
@@ -317,6 +324,9 @@ type
rvPrivilege*: string
rvTable*: string
rvGrantee*: string
of nkSetVar:
svName*: string
svValue*: string
of nkApplyMigration:
amName*: string
of nkMigrationStatus:
@@ -484,6 +494,10 @@ type
discard
of nkPlaceholder:
discard
of nkCurrentUser:
discard
of nkCurrentRole:
discard
of nkPropertyDef:
pdName*: string
pdType*: string
Binary file not shown.
+257 -83
View File
@@ -69,6 +69,7 @@ type
policies*: Table[string, seq[PolicyDef]] # table name -> policies
currentUser*: string
currentRole*: string
sessionVars*: Table[string, string] # session-scoped key/value variables
MigrationRecord* = object
name*: string
@@ -149,6 +150,7 @@ proc newExecutionContext*(db: LSMTree): ExecutionContext =
users: initTable[string, UserDef](),
policies: initTable[string, seq[PolicyDef]](),
currentUser: "", currentRole: "",
sessionVars: initTable[string, string](),
onChange: nil)
restoreSchema(result)
@@ -323,6 +325,7 @@ proc cloneForConnection*(ctx: ExecutionContext): ExecutionContext =
users: ctx.users, policies: ctx.policies,
txnManager: ctx.txnManager,
currentUser: ctx.currentUser, currentRole: ctx.currentRole,
sessionVars: ctx.sessionVars,
pendingTxn: nil, onChange: ctx.onChange)
# ----------------------------------------------------------------------
@@ -504,7 +507,7 @@ proc evalExpr*(expr: IRExpr, row: Table[string, string], ctx: ExecutionContext =
of irekStar:
return "*"
of irekJsonPath:
let srcVal = evalExpr(expr.jpExpr, row)
let srcVal = evalExpr(expr.jpExpr, row, ctx)
if srcVal.len == 0: return ""
try:
let node = parseJson(srcVal)
@@ -527,8 +530,8 @@ proc evalExpr*(expr: IRExpr, row: Table[string, string], ctx: ExecutionContext =
except:
return ""
of irekBinary:
let left = evalExpr(expr.binLeft, row)
let right = evalExpr(expr.binRight, row)
let left = evalExpr(expr.binLeft, row, ctx)
let right = evalExpr(expr.binRight, row, ctx)
case expr.binOp
of irEq:
if left == right: return "true"
@@ -669,20 +672,93 @@ proc evalExpr*(expr: IRExpr, row: Table[string, string], ctx: ExecutionContext =
if vecA.len == 0 or vecB.len == 0:
return "0"
return $vengine.euclideanDistance(vecA, vecB)
of irJsonContains:
# Check if left JSON contains right JSON
try:
let leftNode = parseJson(left)
let rightNode = parseJson(right)
if leftNode.kind == JObject and rightNode.kind == JObject:
for key, val in rightNode:
if not leftNode.hasKey(key) or $(leftNode[key]) != $val:
return "false"
return "true"
elif leftNode.kind == JArray and rightNode.kind == JArray:
for ritem in rightNode:
var found = false
for litem in leftNode:
if $(litem) == $(ritem):
found = true
break
if not found:
return "false"
return "true"
else:
return if $(leftNode) == $(rightNode): "true" else: "false"
except:
return "false"
of irJsonContainedBy:
# Check if left JSON is contained by right JSON (reverse of contains)
try:
let leftNode = parseJson(left)
let rightNode = parseJson(right)
if leftNode.kind == JObject and rightNode.kind == JObject:
for key, val in leftNode:
if not rightNode.hasKey(key) or $(rightNode[key]) != $val:
return "false"
return "true"
elif leftNode.kind == JArray and rightNode.kind == JArray:
for litem in leftNode:
var found = false
for ritem in rightNode:
if $(ritem) == $(litem):
found = true
break
if not found:
return "false"
return "true"
else:
return if $(leftNode) == $(rightNode): "true" else: "false"
except:
return "false"
of irJsonHasAny:
# Check if JSON object has any of the keys in right array
try:
let leftNode = parseJson(left)
let rightNode = parseJson(right)
if leftNode.kind == JObject and rightNode.kind == JArray:
for key in rightNode:
if key.kind == JString and leftNode.hasKey(key.getStr()):
return "true"
return "false"
except:
return "false"
of irJsonHasAll:
# Check if JSON object has all of the keys in right array
try:
let leftNode = parseJson(left)
let rightNode = parseJson(right)
if leftNode.kind == JObject and rightNode.kind == JArray:
for key in rightNode:
if key.kind == JString and not leftNode.hasKey(key.getStr()):
return "false"
return "true"
return "false"
except:
return "false"
else: return "false"
of irekUnary:
case expr.unOp
of irNot:
let v = evalExpr(expr.unExpr, row)
let v = evalExpr(expr.unExpr, row, ctx)
return if v == "true": "false" else: "true"
of irIsNull:
let v = evalExpr(expr.unExpr, row)
let v = evalExpr(expr.unExpr, row, ctx)
return if isNull(v): "true" else: "false"
of irIsNotNull:
let v = evalExpr(expr.unExpr, row)
let v = evalExpr(expr.unExpr, row, ctx)
return if not isNull(v): "true" else: "false"
of irNeg:
let v = evalExpr(expr.unExpr, row)
let v = evalExpr(expr.unExpr, row, ctx)
try:
let f = -parseFloat(v)
let s = $f
@@ -716,6 +792,37 @@ proc evalExpr*(expr: IRExpr, row: Table[string, string], ctx: ExecutionContext =
return "0"
let arg = evalExpr(expr.irFuncArgs[0], row, ctx)
return $parseVectorString(arg).len
of "json_has_key":
if expr.irFuncArgs.len < 2:
return "false"
let jsonStr = evalExpr(expr.irFuncArgs[0], row, ctx)
let key = evalExpr(expr.irFuncArgs[1], row, ctx)
try:
let node = parseJson(jsonStr)
if node.kind == JObject:
return if node.hasKey(key): "true" else: "false"
elif node.kind == JArray:
try:
let idx = parseInt(key)
return if idx >= 0 and idx < node.len: "true" else: "false"
except:
return "false"
return "false"
except:
return "false"
of "current_setting":
if expr.irFuncArgs.len < 1:
return ""
let key = evalExpr(expr.irFuncArgs[0], row, ctx)
if ctx != nil and key in ctx.sessionVars:
return ctx.sessionVars[key]
return ""
of "current_user":
if ctx != nil: return ctx.currentUser
return ""
of "current_role":
if ctx != nil: return ctx.currentRole
return ""
else:
# Unknown function: try to evaluate args and return first arg as fallback
if expr.irFuncArgs.len > 0:
@@ -773,7 +880,7 @@ proc passesPolicy(ctx: ExecutionContext, tableName, command: string, row: Row):
continue
if pol.usingExpr != nil:
let expr = lowerExpr(pol.usingExpr)
if evalExpr(expr, row) != "true":
if evalExpr(expr, row, ctx) != "true":
return false
return true
@@ -788,7 +895,7 @@ proc checkInsertPolicy(ctx: ExecutionContext, tableName: string, row: Row): bool
continue
if pol.withCheckExpr != nil:
let expr = lowerExpr(pol.withCheckExpr)
if evalExpr(expr, row) != "true":
if evalExpr(expr, row, ctx) != "true":
return false
return true
@@ -1151,7 +1258,7 @@ proc validateConstraints*(ctx: ExecutionContext, tableName: string,
else:
row[f] = ""
let checkExpr = lowerExpr(check.checkNode)
let checkResult = evalExpr(checkExpr, row)
let checkResult = evalExpr(checkExpr, row, ctx)
if checkResult != "true":
return (false, "CHECK constraint '" & check.name & "' violated")
@@ -1199,6 +1306,14 @@ proc lowerExpr*(node: Node): IRExpr =
of nkNullLit:
result = IRExpr(kind: irekLiteral)
result.literal = IRLiteral(kind: vkNull)
of nkCurrentUser:
result = IRExpr(kind: irekFuncCall)
result.irFunc = "current_user"
result.irFuncArgs = @[]
of nkCurrentRole:
result = IRExpr(kind: irekFuncCall)
result.irFunc = "current_role"
result.irFuncArgs = @[]
of nkIdent:
result = IRExpr(kind: irekField)
result.fieldPath = @[node.identName]
@@ -1229,6 +1344,10 @@ proc lowerExpr*(node: Node): IRExpr =
of bkOr: irOp = irOr
of bkFtsMatch: irOp = irFtsMatch
of bkDistance: irOp = irDistance
of bkJsonContains: irOp = irJsonContains
of bkJsonContainedBy: irOp = irJsonContainedBy
of bkJsonHasAny: irOp = irJsonHasAny
of bkJsonHasAll: irOp = irJsonHasAll
else: irOp = irEq
result.binOp = irOp
result.binLeft = lowerExpr(node.binLeft)
@@ -1408,7 +1527,7 @@ proc lowerSelect*(node: Node): IRPlan =
filterPlan.filterCond = lowerExpr(node.selWhere.whereExpr)
result = filterPlan
if node.selGroupBy.len > 0:
if node.selGroupBy.len > 0 or node.selGroupingSetsKind != gskNone:
let groupPlan = IRPlan(kind: irpkGroupBy)
groupPlan.groupSource = result
groupPlan.groupKeys = @[]
@@ -1477,17 +1596,17 @@ proc lowerSelect*(node: Node): IRPlan =
# Window Function Computation
# ----------------------------------------------------------------------
proc partitionKey(row: Row, partExprs: seq[IRExpr]): string =
proc partitionKey(row: Row, partExprs: seq[IRExpr], ctx: ExecutionContext = nil): string =
## Compute a string partition key for a row
result = ""
for expr in partExprs:
result &= evalExpr(expr, row) & "|"
result &= evalExpr(expr, row, ctx) & "|"
proc compareRowsByOrder(a, b: Row, orderExprs: seq[IRExpr], orderDirs: seq[bool]): int =
proc compareRowsByOrder(a, b: Row, orderExprs: seq[IRExpr], orderDirs: seq[bool], ctx: ExecutionContext = nil): int =
## Compare two rows by their ORDER BY expressions
for i, expr in orderExprs:
let va = evalExpr(expr, a)
let vb = evalExpr(expr, b)
let va = evalExpr(expr, a, ctx)
let vb = evalExpr(expr, b, ctx)
var cmpRes = 0
try:
let fa = parseFloat(va)
@@ -1500,18 +1619,63 @@ proc compareRowsByOrder(a, b: Row, orderExprs: seq[IRExpr], orderDirs: seq[bool]
return if orderDirs.len > i and orderDirs[i]: -cmpRes else: cmpRes
return 0
proc computeWindowValues*(rows: seq[Row], expr: IRExpr): seq[string] =
proc resolveFrameBounds(pos, partLen: int, frameStart, frameEnd: string): (int, int) =
## Resolve frame boundaries for ROWS mode.
## Returns (startPos, endPos) inclusive within the partition.
var startPos = 0
var endPos = partLen - 1
# Parse start boundary
if frameStart == "UNBOUNDED PRECEDING":
startPos = 0
elif frameStart == "CURRENT ROW":
startPos = pos
elif frameStart.endsWith(" PRECEDING"):
let nStr = frameStart[0..^11]
var n = 0
try: n = parseInt(nStr) except: n = 0
startPos = max(0, pos - n)
elif frameStart.endsWith(" FOLLOWING"):
let nStr = frameStart[0..^11]
var n = 0
try: n = parseInt(nStr) except: n = 0
startPos = min(partLen - 1, pos + n)
# Parse end boundary
if frameEnd == "UNBOUNDED FOLLOWING":
endPos = partLen - 1
elif frameEnd == "CURRENT ROW":
endPos = pos
elif frameEnd.endsWith(" PRECEDING"):
let nStr = frameEnd[0..^11]
var n = 0
try: n = parseInt(nStr) except: n = 0
endPos = max(0, pos - n)
elif frameEnd.endsWith(" FOLLOWING"):
let nStr = frameEnd[0..^11]
var n = 0
try: n = parseInt(nStr) except: n = 0
endPos = min(partLen - 1, pos + n)
if startPos > endPos:
startPos = endPos
return (startPos, endPos)
proc computeWindowValues*(rows: seq[Row], expr: IRExpr, ctx: ExecutionContext = nil): seq[string] =
## Compute a window function for all rows, returning a value per row.
## The expr must be of kind irekWindowFunc.
result = newSeq[string](rows.len)
if rows.len == 0: return
let wfName = expr.wfName.toLower()
let frameMode = expr.wfFrameMode
let frameStart = expr.wfFrameStart
let frameEnd = expr.wfFrameEnd
# Partition rows
var groups = initTable[string, seq[int]]()
for i, row in rows:
let pk = partitionKey(row, expr.wfPartition)
let pk = partitionKey(row, expr.wfPartition, ctx)
if pk notin groups:
groups[pk] = @[]
groups[pk].add(i)
@@ -1520,7 +1684,7 @@ proc computeWindowValues*(rows: seq[Row], expr: IRExpr): seq[string] =
for pk, idxs in groups:
var sortedIdxs = idxs
sortedIdxs.sort(proc(a, b: int): int =
compareRowsByOrder(rows[a], rows[b], expr.wfOrderBy, expr.wfOrderDirs)
compareRowsByOrder(rows[a], rows[b], expr.wfOrderBy, expr.wfOrderDirs, ctx)
)
case wfName
@@ -1531,7 +1695,7 @@ proc computeWindowValues*(rows: seq[Row], expr: IRExpr): seq[string] =
var currentRank = 1
for pos, rowIdx in sortedIdxs:
if pos > 0:
let cmpRes = compareRowsByOrder(rows[sortedIdxs[pos - 1]], rows[rowIdx], expr.wfOrderBy, expr.wfOrderDirs)
let cmpRes = compareRowsByOrder(rows[sortedIdxs[pos - 1]], rows[rowIdx], expr.wfOrderBy, expr.wfOrderDirs, ctx)
if cmpRes != 0:
currentRank = pos + 1
result[rowIdx] = $currentRank
@@ -1539,14 +1703,14 @@ proc computeWindowValues*(rows: seq[Row], expr: IRExpr): seq[string] =
var currentRank = 1
for pos, rowIdx in sortedIdxs:
if pos > 0:
let cmpRes = compareRowsByOrder(rows[sortedIdxs[pos - 1]], rows[rowIdx], expr.wfOrderBy, expr.wfOrderDirs)
let cmpRes = compareRowsByOrder(rows[sortedIdxs[pos - 1]], rows[rowIdx], expr.wfOrderBy, expr.wfOrderDirs, ctx)
if cmpRes != 0:
currentRank += 1
result[rowIdx] = $currentRank
of "ntile":
var n = 1
if expr.wfArgs.len > 0:
try: n = parseInt(evalExpr(expr.wfArgs[0], rows[sortedIdxs[0]])) except: n = 1
try: n = parseInt(evalExpr(expr.wfArgs[0], rows[sortedIdxs[0]], ctx)) except: n = 1
if n < 1: n = 1
let groupSize = sortedIdxs.len div n
let remainder = sortedIdxs.len mod n
@@ -1565,36 +1729,36 @@ proc computeWindowValues*(rows: seq[Row], expr: IRExpr): seq[string] =
var offset = 1
var defaultVal = ""
if expr.wfArgs.len > 1:
try: offset = parseInt(evalExpr(expr.wfArgs[1], rows[sortedIdxs[0]])) except: offset = 1
try: offset = parseInt(evalExpr(expr.wfArgs[1], rows[sortedIdxs[0]], ctx)) except: offset = 1
if expr.wfArgs.len > 2:
defaultVal = evalExpr(expr.wfArgs[2], rows[sortedIdxs[0]])
defaultVal = evalExpr(expr.wfArgs[2], rows[sortedIdxs[0]], ctx)
for pos, rowIdx in sortedIdxs:
let targetPos = pos + offset
if targetPos < sortedIdxs.len:
result[rowIdx] = evalExpr(expr.wfArgs[0], rows[sortedIdxs[targetPos]])
result[rowIdx] = evalExpr(expr.wfArgs[0], rows[sortedIdxs[targetPos]], ctx)
else:
result[rowIdx] = defaultVal
of "lag":
var offset = 1
var defaultVal = ""
if expr.wfArgs.len > 1:
try: offset = parseInt(evalExpr(expr.wfArgs[1], rows[sortedIdxs[0]])) except: offset = 1
try: offset = parseInt(evalExpr(expr.wfArgs[1], rows[sortedIdxs[0]], ctx)) except: offset = 1
if expr.wfArgs.len > 2:
defaultVal = evalExpr(expr.wfArgs[2], rows[sortedIdxs[0]])
defaultVal = evalExpr(expr.wfArgs[2], rows[sortedIdxs[0]], ctx)
for pos, rowIdx in sortedIdxs:
let targetPos = pos - offset
if targetPos >= 0:
result[rowIdx] = evalExpr(expr.wfArgs[0], rows[sortedIdxs[targetPos]])
result[rowIdx] = evalExpr(expr.wfArgs[0], rows[sortedIdxs[targetPos]], ctx)
else:
result[rowIdx] = defaultVal
of "first_value":
let firstVal = evalExpr(expr.wfArgs[0], rows[sortedIdxs[0]])
for rowIdx in sortedIdxs:
result[rowIdx] = firstVal
for pos, rowIdx in sortedIdxs:
let (fStart, fEnd) = resolveFrameBounds(pos, sortedIdxs.len, frameStart, frameEnd)
result[rowIdx] = evalExpr(expr.wfArgs[0], rows[sortedIdxs[fStart]], ctx)
of "last_value":
let lastVal = evalExpr(expr.wfArgs[0], rows[sortedIdxs[^1]])
for rowIdx in sortedIdxs:
result[rowIdx] = lastVal
for pos, rowIdx in sortedIdxs:
let (fStart, fEnd) = resolveFrameBounds(pos, sortedIdxs.len, frameStart, frameEnd)
result[rowIdx] = evalExpr(expr.wfArgs[0], rows[sortedIdxs[fEnd]], ctx)
else:
# Unknown window function — fill with empty
for rowIdx in sortedIdxs:
@@ -1621,8 +1785,12 @@ proc executePlan*(ctx: ExecutionContext, plan: IRPlan): seq[Row] =
result.add(row)
of irpkProject:
let sourceRows = executePlan(ctx, plan.projectSource)
var sourceRows = executePlan(ctx, plan.projectSource)
if plan.projectAliases.len == 0: return sourceRows
# Scalar SELECT (no FROM): create a dummy row so expressions can be evaluated
if sourceRows.len == 0 and plan.projectSource != nil and
plan.projectSource.kind == irpkScan and plan.projectSource.scanTable.len == 0:
sourceRows = @[initTable[string, string]()]
# Check if this projection contains aggregates without GROUP BY
var hasAggs = false
@@ -1660,7 +1828,7 @@ proc executePlan*(ctx: ExecutionContext, plan: IRPlan): seq[Row] =
if not k.startsWith("$") and not k.contains("."):
newRow[k] = v
else:
let val = evalExpr(expr, row)
let val = evalExpr(expr, row, ctx)
if alias.len > 0: newRow[alias] = val
else: newRow["col" & $i] = val
if newRow.len > 0:
@@ -1686,7 +1854,7 @@ proc executePlan*(ctx: ExecutionContext, plan: IRPlan): seq[Row] =
if expr.aggFilter != nil:
filteredRows = @[]
for row in sourceRows:
if evalExpr(expr.aggFilter, row) == "true":
if evalExpr(expr.aggFilter, row, ctx) == "true":
filteredRows.add(row)
case expr.aggOp
of irCount:
@@ -1695,49 +1863,49 @@ proc executePlan*(ctx: ExecutionContext, plan: IRPlan): seq[Row] =
else:
var count = 0
for row in filteredRows:
let v = evalExpr(expr.aggArgs[0], row)
let v = evalExpr(expr.aggArgs[0], row, ctx)
if v.len > 0: count += 1
newRow[alias] = $count
of irSum:
var sum = 0.0
for row in filteredRows:
let v = evalExpr(expr.aggArgs[0], row)
let v = evalExpr(expr.aggArgs[0], row, ctx)
try: sum += parseFloat(v) except: discard
newRow[alias] = $sum
of irAvg:
var sum = 0.0
var count = 0
for row in filteredRows:
let v = evalExpr(expr.aggArgs[0], row)
let v = evalExpr(expr.aggArgs[0], row, ctx)
try: sum += parseFloat(v); count += 1 except: discard
newRow[alias] = if count > 0: $(sum / float(count)) else: "0"
of irMin:
var minVal = ""
for row in filteredRows:
let v = evalExpr(expr.aggArgs[0], row)
let v = evalExpr(expr.aggArgs[0], row, ctx)
if minVal == "" or v < minVal: minVal = v
newRow[alias] = minVal
of irMax:
var maxVal = ""
for row in filteredRows:
let v = evalExpr(expr.aggArgs[0], row)
let v = evalExpr(expr.aggArgs[0], row, ctx)
if maxVal == "" or v > maxVal: maxVal = v
newRow[alias] = maxVal
of irArrayAgg:
var arr: seq[string]
for row in filteredRows:
if expr.aggArgs.len > 0:
arr.add(evalExpr(expr.aggArgs[0], row))
arr.add(evalExpr(expr.aggArgs[0], row, ctx))
newRow[alias] = "[" & arr.join(", ") & "]"
of irStringAgg:
var parts: seq[string]
let delim = if expr.aggArgs.len > 1: evalExpr(expr.aggArgs[1], initTable[string, string]()) else: ","
let delim = if expr.aggArgs.len > 1: evalExpr(expr.aggArgs[1], initTable[string, string](), ctx) else: ","
for row in filteredRows:
if expr.aggArgs.len > 0:
parts.add(evalExpr(expr.aggArgs[0], row))
parts.add(evalExpr(expr.aggArgs[0], row, ctx))
newRow[alias] = parts.join(delim)
else:
let val = evalExpr(expr, if sourceRows.len > 0: sourceRows[0] else: initTable[string, string]())
let val = evalExpr(expr, if sourceRows.len > 0: sourceRows[0] else: initTable[string, string](), ctx)
if alias.len > 0: newRow[alias] = val
else: newRow["col" & $i] = val
result = @[newRow]
@@ -1768,7 +1936,7 @@ proc executePlan*(ctx: ExecutionContext, plan: IRPlan): seq[Row] =
if alias.len > 0: newRow[alias] = "0"
else: newRow["col" & $i] = "0"
else:
let val = evalExpr(expr, row)
let val = evalExpr(expr, row, ctx)
if alias.len > 0: newRow[alias] = val
else: newRow["col" & $i] = val
if newRow.len > 0:
@@ -1782,8 +1950,8 @@ proc executePlan*(ctx: ExecutionContext, plan: IRPlan): seq[Row] =
let sortExpr = plan.sortExprs[0]
let ascending = if plan.sortDirs.len > 0: plan.sortDirs[0] else: true
proc sortCmp(a, b: Row): int =
let va = evalExpr(sortExpr, a)
let vb = evalExpr(sortExpr, b)
let va = evalExpr(sortExpr, a, ctx)
let vb = evalExpr(sortExpr, b, ctx)
try:
let fa = parseFloat(va)
let fb = parseFloat(vb)
@@ -1839,7 +2007,7 @@ proc executePlan*(ctx: ExecutionContext, plan: IRPlan): seq[Row] =
for row in sourceRows:
var groupKey = ""
for gk in gkeys:
groupKey &= evalExpr(gk, row) & "|"
groupKey &= evalExpr(gk, row, ctx) & "|"
if groupKey notin groups:
groups[groupKey] = @[]
groups[groupKey].add(row)
@@ -1847,7 +2015,7 @@ proc executePlan*(ctx: ExecutionContext, plan: IRPlan): seq[Row] =
var aggRow: Table[string, string]
for gkExpr in gkeys:
if gkExpr.kind == irekField and gkExpr.fieldPath.len > 0:
aggRow[gkExpr.fieldPath[^1]] = evalExpr(gkExpr, groupRows[0])
aggRow[gkExpr.fieldPath[^1]] = evalExpr(gkExpr, groupRows[0], ctx)
# Compute each aggregate expression
for aggExpr in plan.groupAggs:
let aggKey = "$agg_" & $aggExpr.aggOp & "_" & $plan.groupAggs.find(aggExpr)
@@ -1855,7 +2023,7 @@ proc executePlan*(ctx: ExecutionContext, plan: IRPlan): seq[Row] =
if aggExpr.aggFilter != nil:
filteredRows = @[]
for row in groupRows:
if evalExpr(aggExpr.aggFilter, row) == "true":
if evalExpr(aggExpr.aggFilter, row, ctx) == "true":
filteredRows.add(row)
case aggExpr.aggOp
of irCount:
@@ -1864,50 +2032,50 @@ proc executePlan*(ctx: ExecutionContext, plan: IRPlan): seq[Row] =
else:
var count = 0
for row in filteredRows:
let v = evalExpr(aggExpr.aggArgs[0], row)
let v = evalExpr(aggExpr.aggArgs[0], row, ctx)
if v.len > 0: count += 1
aggRow[aggKey] = $count
of irSum:
var sum = 0.0
for row in filteredRows:
let v = evalExpr(aggExpr.aggArgs[0], row)
let v = evalExpr(aggExpr.aggArgs[0], row, ctx)
try: sum += parseFloat(v) except: discard
aggRow[aggKey] = $sum
of irAvg:
var sum = 0.0
var count = 0
for row in filteredRows:
let v = evalExpr(aggExpr.aggArgs[0], row)
let v = evalExpr(aggExpr.aggArgs[0], row, ctx)
try: sum += parseFloat(v); count += 1 except: discard
aggRow[aggKey] = if count > 0: $(sum / float(count)) else: "0"
of irMin:
var minVal = ""
for row in filteredRows:
let v = evalExpr(aggExpr.aggArgs[0], row)
let v = evalExpr(aggExpr.aggArgs[0], row, ctx)
if minVal == "" or v < minVal: minVal = v
aggRow[aggKey] = minVal
of irMax:
var maxVal = ""
for row in filteredRows:
let v = evalExpr(aggExpr.aggArgs[0], row)
let v = evalExpr(aggExpr.aggArgs[0], row, ctx)
if maxVal == "" or v > maxVal: maxVal = v
aggRow[aggKey] = maxVal
of irArrayAgg:
var arr: seq[string]
for row in filteredRows:
if aggExpr.aggArgs.len > 0:
arr.add(evalExpr(aggExpr.aggArgs[0], row))
arr.add(evalExpr(aggExpr.aggArgs[0], row, ctx))
aggRow[aggKey] = "[" & arr.join(", ") & "]"
of irStringAgg:
var parts: seq[string]
let delim = if aggExpr.aggArgs.len > 1: evalExpr(aggExpr.aggArgs[1], initTable[string, string]()) else: ","
let delim = if aggExpr.aggArgs.len > 1: evalExpr(aggExpr.aggArgs[1], initTable[string, string](), ctx) else: ","
for row in filteredRows:
if aggExpr.aggArgs.len > 0:
parts.add(evalExpr(aggExpr.aggArgs[0], row))
parts.add(evalExpr(aggExpr.aggArgs[0], row, ctx))
aggRow[aggKey] = parts.join(delim)
# Apply HAVING filter
if plan.groupHaving != nil:
if evalExpr(plan.groupHaving, aggRow) != "true":
if evalExpr(plan.groupHaving, aggRow, ctx) != "true":
continue
result.add(aggRow)
return result
@@ -1979,9 +2147,9 @@ proc executePlan*(ctx: ExecutionContext, plan: IRPlan): seq[Row] =
var mergedRows: seq[Row]
for r in rawRightRows:
let merged = mergeRow(l, r, leftAlias, rightAlias)
if rightFilter != nil and evalExpr(rightFilter, merged) != "true":
if rightFilter != nil and evalExpr(rightFilter, merged, ctx) != "true":
continue
if plan.joinCond != nil and evalExpr(plan.joinCond, merged) != "true":
if plan.joinCond != nil and evalExpr(plan.joinCond, merged, ctx) != "true":
continue
mergedRows.add(merged)
@@ -1989,8 +2157,8 @@ proc executePlan*(ctx: ExecutionContext, plan: IRPlan): seq[Row] =
if rightSortExprs.len > 0 and mergedRows.len > 1:
mergedRows.sort(proc(a, b: Row): int =
for i, sExpr in rightSortExprs:
let aVal = evalExpr(sExpr, a)
let bVal = evalExpr(sExpr, b)
let aVal = evalExpr(sExpr, a, ctx)
let bVal = evalExpr(sExpr, b, ctx)
let asc = if i < rightSortDirs.len: rightSortDirs[i] else: true
var cmp = 0
let aNum = parseFloat(aVal)
@@ -2060,7 +2228,7 @@ proc executePlan*(ctx: ExecutionContext, plan: IRPlan): seq[Row] =
var matched = false
for r in rightRows:
let merged = mergeRow(l, r, leftAlias, rightAlias)
if plan.joinCond == nil or evalExpr(plan.joinCond, merged) == "true":
if plan.joinCond == nil or evalExpr(plan.joinCond, merged, ctx) == "true":
result.add(merged)
matched = true
if not matched and (plan.joinKind == irjkLeft or plan.joinKind == irjkFull):
@@ -2084,7 +2252,7 @@ proc executePlan*(ctx: ExecutionContext, plan: IRPlan): seq[Row] =
var found = false
for l in leftRows:
let merged = mergeRow(l, r, leftAlias, rightAlias)
if plan.joinCond == nil or evalExpr(plan.joinCond, merged) == "true":
if plan.joinCond == nil or evalExpr(plan.joinCond, merged, ctx) == "true":
found = true
break
if not found:
@@ -2152,32 +2320,32 @@ proc executePlan*(ctx: ExecutionContext, plan: IRPlan): seq[Row] =
else:
var count = 0
for row in matchingRows:
let v = evalExpr(plan.pivotAgg.aggArgs[0], row)
let v = evalExpr(plan.pivotAgg.aggArgs[0], row, ctx)
if v.len > 0: count += 1
aggResult = $count
of irSum:
var sum = 0.0
for row in matchingRows:
let v = evalExpr(plan.pivotAgg.aggArgs[0], row)
let v = evalExpr(plan.pivotAgg.aggArgs[0], row, ctx)
try: sum += parseFloat(v) except: discard
aggResult = $sum
of irAvg:
var sum = 0.0
var count = 0
for row in matchingRows:
let v = evalExpr(plan.pivotAgg.aggArgs[0], row)
let v = evalExpr(plan.pivotAgg.aggArgs[0], row, ctx)
try: sum += parseFloat(v); count += 1 except: discard
aggResult = if count > 0: $(sum / float(count)) else: "0"
of irMin:
var minVal = ""
for row in matchingRows:
let v = evalExpr(plan.pivotAgg.aggArgs[0], row)
let v = evalExpr(plan.pivotAgg.aggArgs[0], row, ctx)
if minVal == "" or v < minVal: minVal = v
aggResult = minVal
of irMax:
var maxVal = ""
for row in matchingRows:
let v = evalExpr(plan.pivotAgg.aggArgs[0], row)
let v = evalExpr(plan.pivotAgg.aggArgs[0], row, ctx)
if maxVal == "" or v > maxVal: maxVal = v
aggResult = maxVal
else: discard
@@ -2469,15 +2637,15 @@ proc executeQuery*(ctx: ExecutionContext, astNode: Node, params: seq[WireValue]
let whereIr = lowerExpr(stmt.selWhere.whereExpr)
var tmp: seq[Row] = @[]
for row in filteredRows:
if evalExpr(whereIr, row) == "true":
if evalExpr(whereIr, row, ctx) == "true":
tmp.add(row)
filteredRows = tmp
if stmt.selOrderBy.len > 0:
let sortExpr = lowerExpr(stmt.selOrderBy[0].orderByExpr)
let asc = stmt.selOrderBy[0].orderByDir == sdAsc
proc sortCmp(a, b: Row): int =
let va = evalExpr(sortExpr, a)
let vb = evalExpr(sortExpr, b)
let va = evalExpr(sortExpr, a, ctx)
let vb = evalExpr(sortExpr, b, ctx)
try:
let fa = parseFloat(va)
let fb = parseFloat(vb)
@@ -2798,7 +2966,7 @@ proc executeQuery*(ctx: ExecutionContext, astNode: Node, params: seq[WireValue]
# Check WHERE
if stmt.updWhere != nil and stmt.updWhere.whereExpr != nil:
let whereExpr = lowerExpr(stmt.updWhere.whereExpr)
if evalExpr(whereExpr, row) != "true": continue
if evalExpr(whereExpr, row, ctx) != "true": continue
# Get key from row
if "$key" in row:
let old = row["$key"]
@@ -2839,7 +3007,7 @@ proc executeQuery*(ctx: ExecutionContext, astNode: Node, params: seq[WireValue]
for row in rows:
if stmt.delWhere != nil and stmt.delWhere.whereExpr != nil:
let whereExpr = lowerExpr(stmt.delWhere.whereExpr)
if evalExpr(whereExpr, row) != "true": continue
if evalExpr(whereExpr, row, ctx) != "true": continue
if "$key" in row:
let old = row["$key"]
# Fire BEFORE DELETE triggers
@@ -2879,7 +3047,7 @@ proc executeQuery*(ctx: ExecutionContext, astNode: Node, params: seq[WireValue]
for k, v in tgtRow:
rowWithTarget[stmt.mergeTargetAlias & "." & k] = v
let onExpr = lowerExpr(stmt.mergeOn)
if evalExpr(onExpr, rowWithTarget) == "true":
if evalExpr(onExpr, rowWithTarget, ctx) == "true":
matched = true
if stmt.mergeMatchedUpdate.len > 0 and "$key" in tgtRow:
var updateSets = initTable[string, string]()
@@ -2887,7 +3055,7 @@ proc executeQuery*(ctx: ExecutionContext, astNode: Node, params: seq[WireValue]
if s.kind == nkBinOp and s.binOp == bkAssign:
if s.binLeft.kind == nkIdent:
let valExpr = lowerExpr(s.binRight)
updateSets[s.binLeft.identName] = evalExpr(valExpr, rowWithTarget)
updateSets[s.binLeft.identName] = evalExpr(valExpr, rowWithTarget, ctx)
var newRow = tgtRow
for col, val in updateSets:
newRow[col] = val
@@ -2907,7 +3075,7 @@ proc executeQuery*(ctx: ExecutionContext, astNode: Node, params: seq[WireValue]
if i < stmt.mergeNotMatchedValues.len:
let v = stmt.mergeNotMatchedValues[i]
let valExpr = lowerExpr(v)
values.add(evalExpr(valExpr, combinedRow))
values.add(evalExpr(valExpr, combinedRow, ctx))
else:
values.add("")
if fields.len > 0:
@@ -3284,14 +3452,16 @@ proc executeQuery*(ctx: ExecutionContext, astNode: Node, params: seq[WireValue]
# Full-text search index
var ftsIdx = fts.newInvertedIndex()
let rows = execScan(ctx, stmt.ciTarget)
var docId: uint64 = 0
for row in rows:
let lsmKey = if "$key" in row: row["$key"] else: ""
let docKey = stmt.ciTarget & "." & lsmKey
var docId: uint64 = 0
for ch in docKey:
docId = docId * 31 + uint64(ord(ch))
for col in stmt.ciColumns:
let text = if col in row: row[col] else: ""
if text.len > 0:
ftsIdx.addDocument(docId, text)
let lsmKey = if "$key" in row: row["$key"] else: ""
docId += 1
ctx.ftsIndexes[colKey] = ftsIdx
return okResult(msg="CREATE INDEX " & idxName & " on " & stmt.ciTarget & " USING FTS")
@@ -3426,6 +3596,10 @@ proc executeQuery*(ctx: ExecutionContext, astNode: Node, params: seq[WireValue]
ctx.db.delete(grantKey)
return okResult(msg="REVOKE " & stmt.rvPrivilege & " ON " & stmt.rvTable & " FROM " & stmt.rvGrantee)
of nkSetVar:
ctx.sessionVars[stmt.svName] = stmt.svValue
return okResult(msg="SET " & stmt.svName & " = " & stmt.svValue)
else:
return errResult("Unsupported statement type: " & $stmt.kind)
+4
View File
@@ -29,6 +29,10 @@ type
irIsNull, irIsNotNull
irFtsMatch
irDistance
irJsonContains
irJsonContainedBy
irJsonHasAny
irJsonHasAll
IRAggregate* = enum
irCount, irSum, irAvg, irMin, irMax
+28
View File
@@ -147,6 +147,10 @@ type
tkArrowR # ->
tkArrowRR # ->>
tkFtsMatch # @@
tkJsonContains # @>
tkJsonContainedBy # <@
tkJsonHasAny # ?|
tkJsonHasAll # ?&
tkSimilar
tkNearest
tkTo
@@ -157,12 +161,15 @@ type
# Window functions
tkOver
tkPartition
tkRow
tkRows
tkRange
tkUnbounded
tkPreceding
tkFollowing
tkCurrent
tkCurrentUser
tkCurrentRole
tkRowNumber
tkRank
tkDenseRank
@@ -365,12 +372,15 @@ const keywords*: Table[string, TokenKind] = {
"path": tkPath,
"over": tkOver,
"partition": tkPartition,
"row": tkRow,
"rows": tkRows,
"range": tkRange,
"unbounded": tkUnbounded,
"preceding": tkPreceding,
"following": tkFollowing,
"current": tkCurrent,
"current_user": tkCurrentUser,
"current_role": tkCurrentRole,
"row_number": tkRowNumber,
"rank": tkRank,
"dense_rank": tkDenseRank,
@@ -586,6 +596,10 @@ proc nextToken*(l: var Lexer): Token =
discard l.advance()
discard l.advance()
return Token(kind: tkNotEq, value: "<>", line: startLine, col: startCol)
if l.pos + 1 < l.input.len and l.input[l.pos + 1] == '@':
discard l.advance()
discard l.advance()
return Token(kind: tkJsonContainedBy, value: "<@", line: startLine, col: startCol)
discard l.advance()
return Token(kind: tkLt, value: "<", line: startLine, col: startCol)
of '>':
@@ -596,6 +610,16 @@ proc nextToken*(l: var Lexer): Token =
discard l.advance()
return Token(kind: tkGt, value: ">", line: startLine, col: startCol)
of '?':
if l.pos + 2 < l.input.len and l.input[l.pos + 1] == '|':
discard l.advance()
discard l.advance()
discard l.advance()
return Token(kind: tkJsonHasAny, value: "?|", line: startLine, col: startCol)
if l.pos + 2 < l.input.len and l.input[l.pos + 1] == '&':
discard l.advance()
discard l.advance()
discard l.advance()
return Token(kind: tkJsonHasAll, value: "?&", line: startLine, col: startCol)
if l.pos + 1 < l.input.len and l.input[l.pos + 1] == '?':
discard l.advance()
discard l.advance()
@@ -607,6 +631,10 @@ proc nextToken*(l: var Lexer): Token =
discard l.advance()
discard l.advance()
return Token(kind: tkFtsMatch, value: "@@", line: startLine, col: startCol)
if l.pos + 1 < l.input.len and l.input[l.pos + 1] == '>':
discard l.advance()
discard l.advance()
return Token(kind: tkJsonContains, value: "@>", line: startLine, col: startCol)
discard l.advance()
return Token(kind: tkInvalid, value: "@", line: startLine, col: startCol)
of '.':
+46 -4
View File
@@ -41,6 +41,7 @@ proc parseCreateType(p: var Parser): Node
proc parseOverClause(p: var Parser): Node
proc parseFrameSpec(p: var Parser): Node
proc parseFrameBoundary(p: var Parser): string
proc parseSetVar(p: var Parser): Node
proc parsePrimary(p: var Parser): Node =
let tok = p.peek()
@@ -60,6 +61,12 @@ proc parsePrimary(p: var Parser): Node =
of tkNull:
discard p.advance()
Node(kind: nkNullLit, line: tok.line, col: tok.col)
of tkCurrentUser:
discard p.advance()
Node(kind: nkCurrentUser, line: tok.line, col: tok.col)
of tkCurrentRole:
discard p.advance()
Node(kind: nkCurrentRole, line: tok.line, col: tok.col)
of tkIdent, tkRowNumber, tkRank, tkDenseRank, tkLead, tkLag, tkFirstValue, tkLastValue, tkNtile:
discard p.advance()
let funcName = tok.value
@@ -231,7 +238,7 @@ proc parseFrameBoundary(p: var Parser): string =
else:
raise newException(ValueError, "Expected PRECEDING or FOLLOWING after UNBOUNDED")
elif p.match(tkCurrent):
discard p.expect(tkRows)
discard p.expect(tkRow)
return "CURRENT ROW"
else:
# Expect a simple integer literal for offset boundaries
@@ -318,7 +325,7 @@ proc parseComparison(p: var Parser): Node =
discard p.advance() # consume NULL token (assumed)
return Node(kind: nkIsExpr, isExpr: result, isNegated: negated,
line: tok.line, col: tok.col)
while p.peek().kind in {tkEq, tkNotEq, tkLt, tkLtEq, tkGt, tkGtEq, tkFtsMatch, tkDistanceOp}:
while p.peek().kind in {tkEq, tkNotEq, tkLt, tkLtEq, tkGt, tkGtEq, tkFtsMatch, tkDistanceOp, tkJsonContains, tkJsonContainedBy, tkJsonHasAny, tkJsonHasAll}:
let op = case p.peek().kind
of tkEq: bkEq
of tkNotEq: bkNotEq
@@ -328,6 +335,10 @@ proc parseComparison(p: var Parser): Node =
of tkGtEq: bkGtEq
of tkFtsMatch: bkFtsMatch
of tkDistanceOp: bkDistance
of tkJsonContains: bkJsonContains
of tkJsonContainedBy: bkJsonContainedBy
of tkJsonHasAny: bkJsonHasAny
of tkJsonHasAll: bkJsonHasAll
else: bkEq
let tok = p.advance()
let right = p.parseAddSub()
@@ -1056,13 +1067,13 @@ proc parseAlterTable(p: var Parser): Node =
# Check for ENABLE/DISABLE ROW LEVEL SECURITY
if p.peek().kind == tkEnable:
discard p.advance()
discard p.expect(tkIdent) # ROW
discard p.expect(tkRow) # ROW
discard p.expect(tkIdent) # LEVEL
discard p.expect(tkIdent) # SECURITY
return Node(kind: nkEnableRLS, erlsTable: tableName, line: tok.line, col: tok.col)
elif p.peek().kind == tkDisable:
discard p.advance()
discard p.expect(tkIdent) # ROW
discard p.expect(tkRow) # ROW
discard p.expect(tkIdent) # LEVEL
discard p.expect(tkIdent) # SECURITY
return Node(kind: nkDisableRLS, drlsTable: tableName, line: tok.line, col: tok.col)
@@ -1422,6 +1433,35 @@ proc parseRevoke(p: var Parser): Node =
result = Node(kind: nkRevoke, rvPrivilege: priv, rvTable: tableName,
rvGrantee: grantee, line: tok.line, col: tok.col)
proc parseSetVar(p: var Parser): Node =
let tok = p.expect(tkSet)
var varName = p.expect(tkIdent).value
while p.peek().kind == tkDot:
discard p.advance()
varName.add(".")
varName.add(p.expect(tkIdent).value)
if p.match(tkEq) or p.match(tkTo):
discard
let valTok = p.peek()
var valStr = ""
case valTok.kind
of tkStringLit:
valStr = valTok.value
discard p.advance()
of tkIntLit:
valStr = valTok.value
discard p.advance()
of tkFloatLit:
valStr = valTok.value
discard p.advance()
of tkIdent:
valStr = valTok.value
discard p.advance()
else:
raise newException(ValueError, "Expected value after SET " & varName)
result = Node(kind: nkSetVar, svName: varName, svValue: valStr,
line: tok.line, col: tok.col)
proc parseStatement*(p: var Parser): Node =
case p.peek().kind
of tkWith, tkSelect: p.parseSelect()
@@ -1483,6 +1523,8 @@ proc parseStatement*(p: var Parser): Node =
p.parseGrant()
of tkRevoke:
p.parseRevoke()
of tkSet:
p.parseSetVar()
of tkApply:
if p.pos + 1 < p.tokens.len and p.tokens[p.pos + 1].kind == tkMigration:
p.parseApplyMigration()
+165 -11
View File
@@ -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"