From f7d496112532916ecbc477c0f8ac539a067ab277 Mon Sep 17 00:00:00 2001 From: dimgigov Date: Thu, 14 May 2026 16:28:41 +0300 Subject: [PATCH] feat: Multi-tenant ERP support via session variables + RLS - 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 --- .gitignore | 2 + PLAN_SQL_ADVANCED.md | 57 +++++- docs/en/baraql.md | 41 +++- docs/en/changelog.md | 20 ++ src/barabadb/core/httpserver.nim | 8 +- src/barabadb/core/server.nim | 4 +- src/barabadb/query/ast.nim | 14 ++ src/barabadb/query/executor | Bin 135048 -> 0 bytes src/barabadb/query/executor.nim | 340 +++++++++++++++++++++++-------- src/barabadb/query/ir.nim | 4 + src/barabadb/query/lexer.nim | 28 +++ src/barabadb/query/parser.nim | 50 ++++- tests/test_all.nim | 176 +++++++++++++++- 13 files changed, 642 insertions(+), 102 deletions(-) delete mode 100755 src/barabadb/query/executor diff --git a/.gitignore b/.gitignore index ce1af70..520411d 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/PLAN_SQL_ADVANCED.md b/PLAN_SQL_ADVANCED.md index cd6c0b3..0e2fcbe 100644 --- a/PLAN_SQL_ADVANCED.md +++ b/PLAN_SQL_ADVANCED.md @@ -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` връщаха празни стойности в под-изрази --- diff --git a/docs/en/baraql.md b/docs/en/baraql.md index f4b9061..33920ae 100644 --- a/docs/en/baraql.md +++ b/docs/en/baraql.md @@ -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 | diff --git a/docs/en/changelog.md b/docs/en/changelog.md index 9356412..41d6209 100644 --- a/docs/en/changelog.md +++ b/docs/en/changelog.md @@ -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 diff --git a/src/barabadb/core/httpserver.nim b/src/barabadb/core/httpserver.nim index 9f89e56..1d5b4d3 100644 --- a/src/barabadb/core/httpserver.nim +++ b/src/barabadb/core/httpserver.nim @@ -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) diff --git a/src/barabadb/core/server.nim b/src/barabadb/core/server.nim index 7ae8bb5..55647d9 100644 --- a/src/barabadb/core/server.nim +++ b/src/barabadb/core/server.nim @@ -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) diff --git a/src/barabadb/query/ast.nim b/src/barabadb/query/ast.nim index b600409..e4b9609 100644 --- a/src/barabadb/query/ast.nim +++ b/src/barabadb/query/ast.nim @@ -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 diff --git a/src/barabadb/query/executor b/src/barabadb/query/executor deleted file mode 100755 index 65e04c063efb93c96cc4e6be49b2b7af098ae60a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 135048 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%0D9jiwpw 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 CJ)Y(O diff --git a/src/barabadb/query/executor.nim b/src/barabadb/query/executor.nim index 5ac4022..db69967 100644 --- a/src/barabadb/query/executor.nim +++ b/src/barabadb/query/executor.nim @@ -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) diff --git a/src/barabadb/query/ir.nim b/src/barabadb/query/ir.nim index 5af20c4..141d492 100644 --- a/src/barabadb/query/ir.nim +++ b/src/barabadb/query/ir.nim @@ -29,6 +29,10 @@ type irIsNull, irIsNotNull irFtsMatch irDistance + irJsonContains + irJsonContainedBy + irJsonHasAny + irJsonHasAll IRAggregate* = enum irCount, irSum, irAvg, irMin, irMax diff --git a/src/barabadb/query/lexer.nim b/src/barabadb/query/lexer.nim index f466fc5..2a04207 100644 --- a/src/barabadb/query/lexer.nim +++ b/src/barabadb/query/lexer.nim @@ -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 '.': diff --git a/src/barabadb/query/parser.nim b/src/barabadb/query/parser.nim index 97babec..85c84bb 100644 --- a/src/barabadb/query/parser.nim +++ b/src/barabadb/query/parser.nim @@ -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() diff --git a/tests/test_all.nim b/tests/test_all.nim index 59ec246..6199b57 100644 --- a/tests/test_all.nim +++ b/tests/test_all.nim @@ -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"