diff --git a/clients/nim/baradb.nimble b/clients/nim/baradb.nimble new file mode 100644 index 0000000..e2e05d9 --- /dev/null +++ b/clients/nim/baradb.nimble @@ -0,0 +1,16 @@ +# Package + +version = "0.1.0" +author = "BaraDB Team" +description = "BaraDB client library for Nim — async binary protocol client" +license = "Apache-2.0" +srcDir = "src" + +# Dependencies — only Nim stdlib, no server code +requires "nim >= 2.2.0" + +# Export the client module +bin = @[] + +task test, "Run client tests": + exec "nim c -r tests/test_client.nim" diff --git a/clients/nim/src/baradb/client.nim b/clients/nim/src/baradb/client.nim new file mode 100644 index 0000000..4900117 --- /dev/null +++ b/clients/nim/src/baradb/client.nim @@ -0,0 +1,369 @@ +## BaraDB Client — Self-contained Nim client library +## No dependency on BaraDB server code. +## Communicates via the BaraDB Wire Protocol (binary, big-endian). + +import std/asyncdispatch +import std/asyncnet +import std/strutils +import std/endians + +# === Wire Protocol (self-contained, no server dependency) === + +const + ProtocolMagic* = 0x42415241'u32 + +type + FieldKind* = enum + fkNull = 0x00 + fkBool = 0x01 + fkInt8 = 0x02 + fkInt16 = 0x03 + fkInt32 = 0x04 + fkInt64 = 0x05 + fkFloat32 = 0x06 + fkFloat64 = 0x07 + fkString = 0x08 + fkBytes = 0x09 + fkArray = 0x0A + fkObject = 0x0B + fkVector = 0x0C + + MsgKind* = enum + mkClientHandshake = 0x01 + mkQuery = 0x02 + mkBatch = 0x05 + mkPing = 0x08 + mkAuth = 0x09 + # Server + mkReady = 0x81 + mkData = 0x82 + mkComplete = 0x83 + mkError = 0x84 + mkPong = 0x88 + + ResultFormat* = enum + rfBinary = 0x00 + rfJson = 0x01 + rfText = 0x02 + + WireValue* = object + case kind*: FieldKind + of fkNull: discard + of fkBool: boolVal*: bool + of fkInt8: int8Val*: int8 + of fkInt16: int16Val*: int16 + of fkInt32: int32Val*: int32 + of fkInt64: int64Val*: int64 + of fkFloat32: float32Val*: float32 + of fkFloat64: float64Val*: float64 + of fkString: strVal*: string + of fkBytes: bytesVal*: seq[byte] + of fkArray: arrayVal*: seq[WireValue] + of fkObject: objVal*: seq[(string, WireValue)] + of fkVector: vecVal*: seq[float32] + +proc writeUint32(buf: var seq[byte], val: uint32) = + var bytes: array[4, byte] + bigEndian32(addr bytes, unsafeAddr val) + buf.add(bytes) + +proc writeString(buf: var seq[byte], s: string) = + buf.writeUint32(uint32(s.len)) + for ch in s: + buf.add(byte(ch)) + +proc readUint32(buf: openArray[byte], pos: var int): uint32 = + var bytes: array[4, byte] + for i in 0..3: bytes[i] = buf[pos + i] + bigEndian32(addr result, unsafeAddr bytes) + pos += 4 + +proc readString(buf: openArray[byte], pos: var int): string = + let len = int(readUint32(buf, pos)) + result = newString(len) + for i in 0..= 8: + var epos = 0 + let code = readUint32(payload, epos) + let emsg = readString(payload, epos) + raise newException(IOError, "Error " & $code & ": " & emsg) + if kind == mkComplete: + var rpos = 0 + result.affectedRows = int(readUint32(payload, rpos)) + return + +proc exec*(client: BaraClient, sql: string): Future[int] {.async.} = + let qr = await client.query(sql) + return qr.affectedRows + +proc ping*(client: BaraClient) {.async.} = + if not client.connected: return + let msg = buildMessage(mkPing, client.nextId(), @[]) + await client.socket.send(cast[string](msg)) + +# === Fluent Query Builder === + +type + QueryBuilder* = ref object + client: BaraClient + selectCols: seq[string] + fromTable: string + whereClauses: seq[string] + joinClauses: seq[string] + groupByCols: seq[string] + havingClause: string + orderCols: seq[string] + orderDirs: seq[string] + limitVal: int + offsetVal: int + +proc newQueryBuilder*(client: BaraClient): QueryBuilder = + QueryBuilder(client: client, limitVal: 0, offsetVal: 0) + +proc select*(qb: QueryBuilder, cols: varargs[string]): QueryBuilder = + for c in cols: qb.selectCols.add(c) + return qb + +proc `from`*(qb: QueryBuilder, table: string): QueryBuilder = + qb.fromTable = table + return qb + +proc where*(qb: QueryBuilder, clause: string): QueryBuilder = + qb.whereClauses.add(clause) + return qb + +proc join*(qb: QueryBuilder, table: string, on: string): QueryBuilder = + qb.joinClauses.add("JOIN " & table & " ON " & on) + return qb + +proc leftJoin*(qb: QueryBuilder, table: string, on: string): QueryBuilder = + qb.joinClauses.add("LEFT JOIN " & table & " ON " & on) + return qb + +proc groupBy*(qb: QueryBuilder, cols: varargs[string]): QueryBuilder = + for c in cols: qb.groupByCols.add(c) + return qb + +proc having*(qb: QueryBuilder, clause: string): QueryBuilder = + qb.havingClause = clause + return qb + +proc orderBy*(qb: QueryBuilder, col: string, dir: string = "ASC"): QueryBuilder = + qb.orderCols.add(col) + qb.orderDirs.add(dir) + return qb + +proc limit*(qb: QueryBuilder, n: int): QueryBuilder = + qb.limitVal = n + return qb + +proc offset*(qb: QueryBuilder, n: int): QueryBuilder = + qb.offsetVal = n + return qb + +proc build*(qb: QueryBuilder): string = + result = "SELECT " & (if qb.selectCols.len > 0: qb.selectCols.join(", ") else: "*") + result &= " FROM " & qb.fromTable + for j in qb.joinClauses: result &= " " & j + if qb.whereClauses.len > 0: result &= " WHERE " & qb.whereClauses.join(" AND ") + if qb.groupByCols.len > 0: result &= " GROUP BY " & qb.groupByCols.join(", ") + if qb.havingClause.len > 0: result &= " HAVING " & qb.havingClause + if qb.orderCols.len > 0: + result &= " ORDER BY " + for i, col in qb.orderCols: + if i > 0: result &= ", " + result &= col & " " & qb.orderDirs[i] + if qb.limitVal > 0: result &= " LIMIT " & $qb.limitVal + if qb.offsetVal > 0: result &= " OFFSET " & $qb.offsetVal + +proc exec*(qb: QueryBuilder): Future[QueryResult] {.async.} = + return await qb.client.query(qb.build()) + +# === Sync Wrapper === + +type + SyncClient* = ref object + asyncClient: BaraClient + +proc newSyncClient*(config: ClientConfig = defaultConfig()): SyncClient = + SyncClient(asyncClient: newClient(config)) + +proc connect*(client: SyncClient) = + waitFor client.asyncClient.connect() + +proc close*(client: SyncClient) = + client.asyncClient.close() + +proc query*(client: SyncClient, sql: string): QueryResult = + waitFor client.asyncClient.query(sql) + +proc `$`*(qr: QueryResult): string = + if qr.columns.len == 0: return "(no results)" + result = "" + for i, col in qr.columns: + result &= col + if i < qr.columns.len - 1: result &= ", " + result &= "\n" + for row in qr.rows: + result &= row.join(", ") & "\n" + result &= "(" & $qr.rowCount & " rows)" diff --git a/clients/nim/tests/test_client.nim b/clients/nim/tests/test_client.nim new file mode 100644 index 0000000..57f6d86 --- /dev/null +++ b/clients/nim/tests/test_client.nim @@ -0,0 +1,129 @@ +## BaraDB Nim Client — Tests +import std/unittest +import std/strutils +import baradb/client + +suite "QueryBuilder": + test "Simple SELECT": + let client = newClient() + let qb = newQueryBuilder(client) + let sql = qb.select("name", "age").from("users").build() + check sql == "SELECT name, age FROM users" + + test "SELECT with WHERE": + let client = newClient() + let qb = newQueryBuilder(client) + let sql = qb.select("name").from("users").where("age > 18").build() + check sql == "SELECT name FROM users WHERE age > 18" + + test "SELECT with JOIN": + let client = newClient() + let qb = newQueryBuilder(client) + let sql = qb.select("u.name", "o.total").from("users u") + .join("orders o", "u.id = o.user_id").build() + check "JOIN" in sql + check "ON" in sql + + test "SELECT with GROUP BY and HAVING": + let client = newClient() + let qb = newQueryBuilder(client) + let sql = qb.select("dept", "count(*)").from("employees") + .groupBy("dept").having("count(*) > 5").build() + check "GROUP BY" in sql + check "HAVING" in sql + + test "SELECT with ORDER BY and LIMIT": + let client = newClient() + let qb = newQueryBuilder(client) + let sql = qb.select("name").from("users") + .orderBy("name", "DESC").limit(10).offset(5).build() + check "ORDER BY name DESC" in sql + check "LIMIT 10" in sql + check "OFFSET 5" in sql + + test "Full complex query": + let client = newClient() + let qb = newQueryBuilder(client) + let sql = qb.select("u.name", "count(*) as cnt") + .from("users u") + .leftJoin("orders o", "u.id = o.user_id") + .where("u.age > 18") + .groupBy("u.name") + .having("cnt > 3") + .orderBy("cnt", "DESC") + .limit(50) + .build() + check sql.startsWith("SELECT") + check "LEFT JOIN" in sql + check "WHERE" in sql + check "GROUP BY" in sql + check "HAVING" in sql + check "ORDER BY" in sql + check "LIMIT 50" in sql + +suite "Client Config": + test "Default config": + let config = defaultConfig() + check config.host == "127.0.0.1" + check config.port == 5432 + check config.database == "default" + + test "Custom config": + let config = ClientConfig(host: "db.example.com", port: 9999, + database: "production", username: "app") + check config.host == "db.example.com" + check config.port == 9999 + + test "Client creation": + let client = newClient() + check not client.isConnected + +suite "Wire Protocol": + test "Build query message": + let msg = makeQueryMessage(1, "SELECT 1") + check msg.len > 0 + # First 4 bytes: mkQuery (0x02) in big-endian uint32 + check msg[3] == 2'u8 + + test "Wire value serialization": + var buf: seq[byte] = @[] + let val = WireValue(kind: fkString, strVal: "hello") + buf.serializeValue(val) + var pos = 0 + let decoded = buf.deserializeValue(pos) + check decoded.kind == fkString + check decoded.strVal == "hello" + + test "Null value roundtrip": + var buf: seq[byte] = @[] + let val = WireValue(kind: fkNull) + buf.serializeValue(val) + check buf.len == 1 + check buf[0] == 0x00'u8 + + test "Int64 value roundtrip": + var buf: seq[byte] = @[] + let val = WireValue(kind: fkInt64, int64Val: 123456789) + buf.serializeValue(val) + var pos = 0 + let decoded = buf.deserializeValue(pos) + check decoded.kind == fkInt64 + check decoded.int64Val == 123456789 + + test "Array value roundtrip": + var buf: seq[byte] = @[] + let val = WireValue(kind: fkArray, arrayVal: @[ + WireValue(kind: fkString, strVal: "a"), + WireValue(kind: fkString, strVal: "b"), + ]) + buf.serializeValue(val) + var pos = 0 + let decoded = buf.deserializeValue(pos) + check decoded.kind == fkArray + check decoded.arrayVal.len == 2 + check decoded.arrayVal[0].strVal == "a" + +suite "Client async": + test "Client nextId": + let client = newClient() + check not client.isConnected