tricoredb 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,979 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module TriCoreDB
6
+ # Optional protocol capabilities, negotiated in HELLO as a bitmap.
7
+ module Features
8
+ # The server echoes `correlation_id` into its logs and traces.
9
+ CORRELATION_ID = 1
10
+ # The server binds `?` placeholders itself, from a typed `params` array.
11
+ SERVER_PARAMS = 2
12
+ # `BEGIN`, statements and `COMMIT`/`ROLLBACK` as separate requests on one connection.
13
+ SESSION_TXN = 4
14
+ # Every capability this driver understands.
15
+ ALL = CORRELATION_ID | SERVER_PARAMS | SESSION_TXN
16
+ end
17
+
18
+ # One connection to a TriCoreDB server, speaking the native `tricore` protocol.
19
+ #
20
+ # A connection is a single request/response stream. Calls from several threads
21
+ # are serialised by an internal lock, so they are safe but not concurrent; use
22
+ # a {Pool} for concurrency.
23
+ #
24
+ # @example
25
+ # db = TriCoreDB.connect(host: "127.0.0.1", port: 8427, user: "admin", secret: ENV["TRICORE_SECRET"])
26
+ # db.execute("INSERT INTO t VALUES (?, ?)", [1, "ada"])
27
+ # db.query("SELECT name FROM t WHERE id = ?", [1]).rows # => [["ada"]]
28
+ # db.close
29
+ class Client
30
+ DEFAULT_PORT = 8427
31
+ DEFAULT_DATABASE = "main"
32
+ CLOSE_TIMEOUT = 2
33
+
34
+ # @return [Integer] the feature bitmap the server granted in HELLO_OK
35
+ attr_reader :granted_features
36
+ # @return [String, nil] the session id issued by AUTH_OK
37
+ attr_reader :session_id
38
+ # @return [String, nil] the request id most recently sent; pass it to {#cancel} on another connection
39
+ attr_reader :last_request_id
40
+ # @return [String] the database used when a call does not name one
41
+ attr_accessor :database
42
+ # @return [Numeric, nil] client-side seconds to wait for a reply; nil (the default) waits for as long as the statement runs
43
+ attr_accessor :read_timeout
44
+ # @return [Integer, nil] a server-side deadline in milliseconds sent with every request
45
+ attr_accessor :request_timeout_ms
46
+
47
+ # Connect, handshake and (when `user` is given) authenticate.
48
+ #
49
+ # @param host [String]
50
+ # @param port [Integer]
51
+ # @param user [String, nil]
52
+ # @param secret [String] password or token; sent as its bytes
53
+ # @param database [String]
54
+ # @param connect_timeout [Numeric, nil] seconds covering TCP connect, TLS, HELLO and AUTH
55
+ # @param read_timeout [Numeric, nil] seconds to wait for each reply after connecting
56
+ # @param request_timeout_ms [Integer, nil] server-side deadline for every request
57
+ # @param tls [Hash, true, nil] see {Transport.tls_context}
58
+ # @param client_name [String]
59
+ # @param features [Integer] capabilities to request
60
+ # @return [Client]
61
+ # @raise [ConnectionError, ProtocolError, AuthError]
62
+ def self.connect(host: "127.0.0.1", port: DEFAULT_PORT, user: nil, secret: "", database: DEFAULT_DATABASE,
63
+ connect_timeout: 10, read_timeout: nil, request_timeout_ms: nil, tls: nil,
64
+ client_name: "tricoredb-ruby/#{VERSION}", features: Features::ALL)
65
+ transport = Transport.open(host, Integer(port), connect_timeout: connect_timeout, tls: tls)
66
+ client = new(transport, database: database)
67
+ begin
68
+ client.handshake(client_name: client_name, features: features, timeout: connect_timeout)
69
+ client.authenticate(user, secret, timeout: connect_timeout) unless user.nil?
70
+ rescue Exception # rubocop:disable Lint/RescueException
71
+ transport.close
72
+ raise
73
+ end
74
+ client.read_timeout = read_timeout
75
+ client.request_timeout_ms = request_timeout_ms
76
+ client
77
+ end
78
+
79
+ # Wrap an already-open transport. {Client.connect} is the usual entry point.
80
+ #
81
+ # @param transport [Transport]
82
+ # @param database [String]
83
+ def initialize(transport, database: DEFAULT_DATABASE)
84
+ @transport = transport
85
+ @database = database
86
+ @lock = Mutex.new
87
+ @rid = 0
88
+ @rid_prefix = "rb-#{Process.pid.to_s(16)}-#{SecureRandom.hex(6)}"
89
+ @granted_features = 0
90
+ @txn_open = false
91
+ @read_timeout = nil
92
+ @request_timeout_ms = nil
93
+ end
94
+
95
+ # @return [Hash{Symbol => Object}] the granted features by name, plus `:mask`
96
+ def features
97
+ g = @granted_features
98
+ {
99
+ mask: g,
100
+ correlation_id: g & Features::CORRELATION_ID != 0,
101
+ server_params: g & Features::SERVER_PARAMS != 0,
102
+ session_txn: g & Features::SESSION_TXN != 0
103
+ }
104
+ end
105
+
106
+ # @return [Boolean]
107
+ def closed?
108
+ @transport.closed?
109
+ end
110
+
111
+ # @return [Boolean] true from a successful {#begin_transaction} until commit or rollback
112
+ def in_transaction?
113
+ @txn_open && !closed?
114
+ end
115
+
116
+ # Send HELLO and read HELLO_OK. Called by {Client.connect}.
117
+ # @return [Integer] the granted feature bitmap
118
+ def handshake(client_name: "tricoredb-ruby/#{VERSION}", features: Features::ALL, timeout: 10)
119
+ tag, body = exchange(Frame::HELLO, {
120
+ "protocol" => "tricore",
121
+ "version" => { "major" => 1, "minor" => 0 },
122
+ "client" => client_name,
123
+ "features" => features
124
+ }, timeout: timeout)
125
+ if tag == Frame::ERROR
126
+ fail_stream(ProtocolError.new(error_text(body), code: error_code(body)))
127
+ end
128
+ fail_stream(ProtocolError.new("expected HELLO_OK, got #{Frame.name(tag)}")) unless tag == Frame::HELLO_OK
129
+ unless body.is_a?(Hash) && body["ok"] == true
130
+ message = body.is_a?(Hash) ? body["message"] : nil
131
+ fail_stream(ProtocolError.new(message || "handshake refused", code: body.is_a?(Hash) ? body["code"] : nil))
132
+ end
133
+ @granted_features = Integer(body["features"] || 0)
134
+ end
135
+
136
+ # Send AUTH and read AUTH_OK.
137
+ #
138
+ # A refused login arrives as AUTH_OK with `ok: false`, not as an ERROR frame;
139
+ # both are raised as {AuthError}.
140
+ #
141
+ # @return [String, nil] the session id
142
+ # @raise [AuthError]
143
+ def authenticate(user, secret, timeout: @read_timeout)
144
+ tag, body = exchange(Frame::AUTH, { "username" => user.to_s, "secret" => secret.to_s.bytes }, timeout: timeout)
145
+ raise AuthError.new(error_text(body), code: error_code(body)) if tag == Frame::ERROR
146
+ fail_stream(ProtocolError.new("expected AUTH_OK, got #{Frame.name(tag)}")) unless tag == Frame::AUTH_OK
147
+ unless body.is_a?(Hash) && body["ok"] == true
148
+ raise AuthError, (body.is_a?(Hash) && body["message"]) || "authentication refused"
149
+ end
150
+
151
+ @session_id = body["session_id"]
152
+ end
153
+
154
+ # Exchange PING/PONG. Never reaches a module; see {#admin_ping} for a full round trip.
155
+ # @return [true]
156
+ def ping
157
+ tag, _body = exchange(Frame::PING, nil)
158
+ fail_stream(ProtocolError.new("expected PONG, got #{Frame.name(tag)}")) unless tag == Frame::PONG
159
+ true
160
+ end
161
+
162
+ # Ask the server to stop one of this principal's running statements.
163
+ #
164
+ # Send this on a **second connection**: the connection running the statement
165
+ # is waiting for its reply and cannot carry anything else.
166
+ #
167
+ # @param request_id [String]
168
+ # @return [Integer] how many executions were cancelled (0 for an unknown id)
169
+ def cancel(request_id)
170
+ tag, body = exchange(Frame::CANCEL, { "request_id" => request_id.to_s })
171
+ raise ServerError.new(error_text(body), code: error_code(body), frame: true) if tag == Frame::ERROR
172
+ fail_stream(ProtocolError.new("expected CANCEL_OK, got #{Frame.name(tag)}")) unless tag == Frame::CANCEL_OK
173
+ body.is_a?(Hash) ? Integer(body["cancelled"] || 0) : 0
174
+ end
175
+
176
+ # Say goodbye and close the socket. Idempotent; never raises.
177
+ def close
178
+ return if closed?
179
+
180
+ begin
181
+ exchange(Frame::CLOSE, nil, timeout: CLOSE_TIMEOUT)
182
+ rescue StandardError
183
+ nil
184
+ ensure
185
+ @transport.close
186
+ end
187
+ nil
188
+ end
189
+
190
+ # Send a raw operation.
191
+ #
192
+ # @param op [Hash, String] an externally tagged TriCoreOp, e.g. `{"Cache" => "Ping"}`
193
+ # @param database [String]
194
+ # @param correlation_id [String, nil] requires the CORRELATION_ID feature
195
+ # @return [Response] only when its status is `ok`
196
+ # @raise [ServerError] for any other status, or an ERROR frame
197
+ def request(op, database: @database, correlation_id: nil)
198
+ payload = {
199
+ "request_id" => next_request_id,
200
+ "database" => database,
201
+ "region_hint" => nil,
202
+ "op" => op
203
+ }
204
+ unless correlation_id.nil?
205
+ require_feature(Features::CORRELATION_ID, "CORRELATION_ID", "a correlation id")
206
+ payload["correlation_id"] = correlation_id.to_s
207
+ end
208
+ unless @request_timeout_ms.nil?
209
+ payload["options"] = {
210
+ "cache" => { "mode" => "disabled" },
211
+ "output" => "native",
212
+ "consistency" => "strong_primary",
213
+ "timeout_ms" => Integer(@request_timeout_ms),
214
+ "llm" => nil
215
+ }
216
+ end
217
+ tag, body = exchange(Frame::REQUEST, payload)
218
+ raise ServerError.new(error_text(body), code: error_code(body), frame: true) if tag == Frame::ERROR
219
+ fail_stream(ProtocolError.new("expected RESPONSE, got #{Frame.name(tag)}")) unless tag == Frame::RESPONSE
220
+
221
+ response = Response.new(body)
222
+ raise response.to_error(@txn_open) unless response.ok?
223
+
224
+ response
225
+ end
226
+
227
+ # ---- SQL -----------------------------------------------------------------
228
+
229
+ # Run a write or DDL statement.
230
+ #
231
+ # @param sql [String]
232
+ # @param params [Array, nil] values for `?` placeholders, bound by the server; see {Params}
233
+ # @return [Response] use {Response#rows_affected}
234
+ # @raise [FeatureNotGranted] when params are given and SERVER_PARAMS was not granted
235
+ def execute(sql, params = nil, database: @database)
236
+ request({ "Sql" => { "Exec" => sql_body(sql, params) } }, database: database)
237
+ end
238
+
239
+ # Run a read. The server refuses a write sent this way.
240
+ #
241
+ # @param sql [String]
242
+ # @param params [Array, nil]
243
+ # @return [Rows]
244
+ def query(sql, params = nil, database: @database)
245
+ resp = request({ "Sql" => { "Query" => sql_body(sql, params) } }, database: database)
246
+ rows = resp.arm("Rows")
247
+ raise ProtocolError, "expected Rows, got #{resp.kind}" unless rows.is_a?(Hash)
248
+
249
+ Rows.new(rows["columns"] || [], rows["rows"] || [])
250
+ end
251
+
252
+ # ---- session transactions ---------------------------------------------------
253
+
254
+ # Open a session transaction on this connection. Requires SESSION_TXN.
255
+ # @return [Hash] the server's outcome
256
+ # @raise [FeatureNotGranted]
257
+ def begin_transaction(database: @database)
258
+ require_feature(Features::SESSION_TXN, "SESSION_TXN",
259
+ "begin/commit/rollback (send a whole `BEGIN; ...; COMMIT` script with #execute instead)")
260
+ txn_control("BEGIN", database)
261
+ end
262
+ alias begin begin_transaction
263
+
264
+ # Commit the open block.
265
+ # @return [Hash]
266
+ def commit(database: @database)
267
+ txn_control("COMMIT", database)
268
+ end
269
+
270
+ # Discard the open block.
271
+ # @return [Hash]
272
+ def rollback(database: @database)
273
+ txn_control("ROLLBACK", database)
274
+ end
275
+
276
+ # Begin, yield this connection, and commit. If the block raises, roll back
277
+ # and re-raise the original exception.
278
+ #
279
+ # @yieldparam db [Client]
280
+ # @return [Object] the block's value
281
+ def transaction(database: @database)
282
+ raise ArgumentError, "transaction needs a block" unless block_given?
283
+
284
+ begin_transaction(database: database)
285
+ begin
286
+ result = yield self
287
+ rescue Exception # rubocop:disable Lint/RescueException
288
+ if in_transaction?
289
+ begin
290
+ rollback(database: database)
291
+ rescue StandardError
292
+ nil
293
+ end
294
+ end
295
+ raise
296
+ end
297
+ commit(database: database)
298
+ result
299
+ end
300
+
301
+ # ---- cache ---------------------------------------------------------------
302
+ #
303
+ # Values are bytes. Pass any String (its bytes are sent as they are) or an
304
+ # Array of Integers; values come back as binary (ASCII-8BIT) Strings.
305
+
306
+ # Liveness through the cache module.
307
+ def cache_ping(database: @database)
308
+ request({ "Cache" => "Ping" }, database: database)
309
+ true
310
+ end
311
+
312
+ def cache_set(namespace, key, value, ttl_ms: nil, database: @database)
313
+ cache("Set", { "namespace" => namespace, "key" => key, "value" => bytes(value, "value"), "ttl_ms" => ttl_ms }, database)
314
+ nil
315
+ end
316
+
317
+ # @return [String, nil] binary String, nil on a miss
318
+ def cache_get(namespace, key, database: @database)
319
+ cache_value(cache("Get", nk(namespace, key), database), "get")
320
+ end
321
+
322
+ # @return [Boolean] whether the key existed
323
+ def cache_delete(namespace, key, database: @database)
324
+ json(cache("Delete", nk(namespace, key), database), "delete")["deleted"] == true
325
+ end
326
+
327
+ # @return [Boolean]
328
+ def cache_exists?(namespace, key, database: @database)
329
+ json(cache("Exists", nk(namespace, key), database), "exists")["exists"] == true
330
+ end
331
+
332
+ # @return [Integer, nil] remaining milliseconds; nil when the key is missing or has no expiry
333
+ def cache_ttl(namespace, key, database: @database)
334
+ ttl = json(cache("Ttl", nk(namespace, key), database), "ttl")["ttl_ms"]
335
+ ttl.is_a?(Integer) ? ttl : nil
336
+ end
337
+
338
+ # @return [Integer] keys removed
339
+ def cache_clear_namespace(namespace, database: @database)
340
+ json(cache("ClearNamespace", { "namespace" => namespace }, database), "clear")["cleared"]
341
+ end
342
+
343
+ # @return [Integer] the new value
344
+ def cache_incr(namespace, key, by = 1, database: @database)
345
+ json(cache("Incr", nk(namespace, key).merge("by" => Integer(by)), database), "incr")["value"]
346
+ end
347
+
348
+ # @return [Boolean] false when the key does not exist
349
+ def cache_expire(namespace, key, ttl_ms, database: @database)
350
+ json(cache("Expire", nk(namespace, key).merge("ttl_ms" => Integer(ttl_ms)), database), "expire")["updated"] == true
351
+ end
352
+
353
+ # @return [Boolean] false when the key had no TTL
354
+ def cache_persist(namespace, key, database: @database)
355
+ json(cache("Persist", nk(namespace, key), database), "persist")["persisted"] == true
356
+ end
357
+
358
+ # Set only if absent.
359
+ # @return [Boolean] whether this call stored the value
360
+ def cache_set_nx(namespace, key, value, ttl_ms: nil, database: @database)
361
+ body = nk(namespace, key).merge("value" => bytes(value, "value"), "ttl_ms" => ttl_ms)
362
+ json(cache("SetNx", body, database), "setnx")["set"] == true
363
+ end
364
+
365
+ # @param pattern [String, nil] a glob where `*` matches any run of characters
366
+ # @return [Array<Hash>] one Hash per key (`"key"`, and the TTL and size the server reports)
367
+ def cache_keys(namespace, pattern: nil, limit: nil, database: @database)
368
+ json(cache("Keys", { "namespace" => namespace, "pattern" => pattern, "limit" => limit }, database), "keys")["keys"] || []
369
+ end
370
+
371
+ # @return [Integer] the new length
372
+ def cache_lpush(namespace, key, values, database: @database)
373
+ json(cache("LPush", nk(namespace, key).merge("values" => byte_list(values, "values")), database), "lpush")["length"]
374
+ end
375
+
376
+ # @return [Integer] the new length
377
+ def cache_rpush(namespace, key, values, database: @database)
378
+ json(cache("RPush", nk(namespace, key).merge("values" => byte_list(values, "values")), database), "rpush")["length"]
379
+ end
380
+
381
+ # @return [String, nil]
382
+ def cache_lpop(namespace, key, database: @database)
383
+ cache_value(cache("LPop", nk(namespace, key), database), "lpop")
384
+ end
385
+
386
+ # @return [String, nil]
387
+ def cache_rpop(namespace, key, database: @database)
388
+ cache_value(cache("RPop", nk(namespace, key), database), "rpop")
389
+ end
390
+
391
+ # Inclusive range; negative indices count from the end.
392
+ # @return [Array<String>]
393
+ def cache_lrange(namespace, key, start, stop, database: @database)
394
+ body = nk(namespace, key).merge("start" => Integer(start), "stop" => Integer(stop))
395
+ binaries(json(cache("LRange", body, database), "lrange")["values"])
396
+ end
397
+
398
+ # @return [Integer]
399
+ def cache_llen(namespace, key, database: @database)
400
+ json(cache("LLen", nk(namespace, key), database), "llen")["length"]
401
+ end
402
+
403
+ # @return [String, nil]
404
+ def cache_lindex(namespace, key, index, database: @database)
405
+ cache_value(cache("LIndex", nk(namespace, key).merge("index" => Integer(index)), database), "lindex")
406
+ end
407
+
408
+ # @return [Integer] members newly added
409
+ def cache_sadd(namespace, key, members, database: @database)
410
+ json(cache("SAdd", nk(namespace, key).merge("members" => byte_list(members, "members")), database), "sadd")["added"]
411
+ end
412
+
413
+ # @return [Integer] members that were present
414
+ def cache_srem(namespace, key, members, database: @database)
415
+ json(cache("SRem", nk(namespace, key).merge("members" => byte_list(members, "members")), database), "srem")["removed"]
416
+ end
417
+
418
+ # @return [Boolean]
419
+ def cache_sismember?(namespace, key, member, database: @database)
420
+ body = nk(namespace, key).merge("member" => bytes(member, "member"))
421
+ json(cache("SIsMember", body, database), "sismember")["is_member"] == true
422
+ end
423
+
424
+ # @return [Integer]
425
+ def cache_scard(namespace, key, database: @database)
426
+ json(cache("SCard", nk(namespace, key), database), "scard")["cardinality"]
427
+ end
428
+
429
+ # @return [Array<String>] in ascending byte order
430
+ def cache_smembers(namespace, key, database: @database)
431
+ binaries(json(cache("SMembers", nk(namespace, key), database), "smembers")["members"])
432
+ end
433
+
434
+ # @param entries [Hash, Array<Array(String, String)>] field => value
435
+ # @return [Integer] fields newly created
436
+ def cache_hset(namespace, key, entries, database: @database)
437
+ json(cache("HSet", nk(namespace, key).merge("entries" => pairs(entries, "entries")), database), "hset")["created"]
438
+ end
439
+
440
+ # @return [String, nil]
441
+ def cache_hget(namespace, key, field, database: @database)
442
+ cache_value(cache("HGet", nk(namespace, key).merge("field" => bytes(field, "field")), database), "hget")
443
+ end
444
+
445
+ # @return [Integer] fields that were present
446
+ def cache_hdel(namespace, key, fields, database: @database)
447
+ json(cache("HDel", nk(namespace, key).merge("fields" => byte_list(fields, "fields")), database), "hdel")["deleted"]
448
+ end
449
+
450
+ # @return [Array<Array(String, String)>] binary pairs in ascending field order
451
+ def cache_hgetall(namespace, key, database: @database)
452
+ (json(cache("HGetAll", nk(namespace, key), database), "hgetall")["entries"] || []).map do |f, v|
453
+ [f.pack("C*"), v.pack("C*")]
454
+ end
455
+ end
456
+
457
+ # @return [Boolean]
458
+ def cache_hexists?(namespace, key, field, database: @database)
459
+ json(cache("HExists", nk(namespace, key).merge("field" => bytes(field, "field")), database), "hexists")["exists"] == true
460
+ end
461
+
462
+ # @return [Integer]
463
+ def cache_hlen(namespace, key, database: @database)
464
+ json(cache("HLen", nk(namespace, key), database), "hlen")["length"]
465
+ end
466
+
467
+ # Append a stream entry.
468
+ # @param fields [Hash, Array<Array(String, String)>]
469
+ # @param id [String, nil] nil or `*` to auto-generate
470
+ # @return [String] the assigned `<ms>-<seq>` id
471
+ def cache_xadd(namespace, key, fields, id: nil, database: @database)
472
+ body = nk(namespace, key).merge("id" => id, "fields" => pairs(fields, "fields"))
473
+ json(cache("XAdd", body, database), "xadd")["id"]
474
+ end
475
+
476
+ # @return [Integer]
477
+ def cache_xlen(namespace, key, database: @database)
478
+ json(cache("XLen", nk(namespace, key), database), "xlen")["length"]
479
+ end
480
+
481
+ # @return [Array<StreamEntry>] oldest first
482
+ def cache_xrange(namespace, key, start = "-", stop = "+", count: nil, database: @database)
483
+ body = nk(namespace, key).merge("start" => start, "end" => stop, "count" => count)
484
+ stream_entries(json(cache("XRange", body, database), "xrange"))
485
+ end
486
+
487
+ # Entries strictly newer than `after`. Never blocks.
488
+ # @return [Array<StreamEntry>]
489
+ def cache_xread(namespace, key, after = "0-0", count: nil, database: @database)
490
+ body = nk(namespace, key).merge("after" => after, "count" => count)
491
+ stream_entries(json(cache("XRead", body, database), "xread"))
492
+ end
493
+
494
+ # @return [Integer] entries deleted
495
+ def cache_xdel(namespace, key, ids, database: @database)
496
+ json(cache("XDel", nk(namespace, key).merge("ids" => Array(ids).map(&:to_s)), database), "xdel")["deleted"]
497
+ end
498
+
499
+ # @return [Integer] entries evicted
500
+ def cache_xtrim(namespace, key, max_len, database: @database)
501
+ json(cache("XTrim", nk(namespace, key).merge("max_len" => Integer(max_len)), database), "xtrim")["trimmed"]
502
+ end
503
+
504
+ # ---- document --------------------------------------------------------------
505
+
506
+ def doc_create_collection(collection, database: @database)
507
+ document("CreateCollection", { "collection" => collection }, database)
508
+ nil
509
+ end
510
+
511
+ # @param id [String, nil] omitted, the server generates one
512
+ # @return [String] the stored id
513
+ def doc_insert(collection, doc, id: nil, database: @database)
514
+ json(document("Insert", { "collection" => collection, "id" => id, "document" => doc }, database), "insert")["id"]
515
+ end
516
+
517
+ # @return [Hash, nil]
518
+ def doc_get(collection, id, database: @database)
519
+ documents(document("Get", { "collection" => collection, "id" => id }, database), "get").first
520
+ end
521
+
522
+ # @param filter [String, Hash] from {Filter}
523
+ # @return [Array<Hash>]
524
+ def doc_find(collection, filter = Filter.all, limit: nil, database: @database)
525
+ documents(document("Find", { "collection" => collection, "filter" => filter, "limit" => limit }, database), "find")
526
+ end
527
+
528
+ # Set fields on an existing document (dot paths). Not an upsert.
529
+ def doc_update(collection, id, set, database: @database)
530
+ document("Update", { "collection" => collection, "id" => id, "set" => set }, database)
531
+ nil
532
+ end
533
+
534
+ # @return [Hash] `"updated"`, `"inserted"`, `"id"`
535
+ def doc_update_one(collection, id, set: nil, inc: nil, upsert: false, database: @database)
536
+ body = { "collection" => collection, "id" => id, "update" => update_body(set, inc), "upsert" => upsert ? true : false }
537
+ json(document("UpdateOne", body, database), "updateOne")
538
+ end
539
+
540
+ # @return [Hash] `"matched"`, `"modified"`
541
+ def doc_update_many(collection, filter, set: nil, inc: nil, database: @database)
542
+ body = { "collection" => collection, "filter" => filter, "update" => update_body(set, inc) }
543
+ json(document("UpdateMany", body, database), "updateMany")
544
+ end
545
+
546
+ def doc_delete(collection, id, database: @database)
547
+ document("Delete", { "collection" => collection, "id" => id }, database)
548
+ nil
549
+ end
550
+
551
+ # @return [Array<String>]
552
+ def doc_list_collections(database: @database)
553
+ json(request({ "Document" => "ListCollections" }, database: database), "listCollections")["collections"] || []
554
+ end
555
+
556
+ def doc_drop_collection(collection, database: @database)
557
+ document("DropCollection", { "collection" => collection }, database)
558
+ nil
559
+ end
560
+
561
+ def doc_create_index(collection, index_name, field, unique: false, database: @database)
562
+ body = { "collection" => collection, "index_name" => index_name, "field" => field, "unique" => unique ? true : false }
563
+ document("CreateIndex", body, database)
564
+ nil
565
+ end
566
+
567
+ def doc_drop_index(collection, index_name, database: @database)
568
+ document("DropIndex", { "collection" => collection, "index_name" => index_name }, database)
569
+ nil
570
+ end
571
+
572
+ # @return [Array<Hash>] `"index_name"`, `"field"`, `"unique"`
573
+ def doc_list_indexes(collection, database: @database)
574
+ json(document("ListIndexes", { "collection" => collection }, database), "listIndexes")["indexes"] || []
575
+ end
576
+
577
+ # @return [Hash] `"document_count"` and the other statistics
578
+ def doc_analyze(collection, database: @database)
579
+ json(document("Analyze", { "collection" => collection }, database), "analyze")
580
+ end
581
+
582
+ # @param pipeline [Array<Hash>] from {Stage}
583
+ # @return [Array<Hash>]
584
+ def doc_aggregate(collection, pipeline, database: @database)
585
+ documents(document("Aggregate", { "collection" => collection, "pipeline" => pipeline }, database), "aggregate")
586
+ end
587
+
588
+ # ---- vector ----------------------------------------------------------------
589
+
590
+ # @param metric [String] `cosine`, `dot` or `l2`
591
+ # @param quantization [String] `none` or `int8`
592
+ def vector_create_collection(collection, dimension, metric: "cosine", quantization: "none", database: @database)
593
+ body = { "collection" => collection, "dimension" => Integer(dimension), "metric" => metric.to_s,
594
+ "quantization" => quantization.to_s }
595
+ vector("CreateCollection", body, database)
596
+ nil
597
+ end
598
+
599
+ # @return [String] the id
600
+ def vector_upsert(collection, id, values, metadata: nil, database: @database)
601
+ body = { "collection" => collection, "id" => id, "vector" => numbers(values), "metadata" => metadata }
602
+ json(vector("Upsert", body, database), "upsert")["id"]
603
+ end
604
+
605
+ # @return [Hash, nil] `"id"`, `"vector"`, `"metadata"`
606
+ def vector_get(collection, id, database: @database)
607
+ json(vector("Get", { "collection" => collection, "id" => id }, database), "vector get")
608
+ end
609
+
610
+ def vector_delete(collection, id, database: @database)
611
+ vector("Delete", { "collection" => collection, "id" => id }, database)
612
+ nil
613
+ end
614
+
615
+ # @param filter [Hash, nil] metadata field => required value (exact equality)
616
+ # @return [Array<Hash>] `"id"`, `"score"`, `"metadata"`, best first
617
+ def vector_search(collection, values, top_k, filter: nil, database: @database)
618
+ body = { "collection" => collection, "vector" => numbers(values), "top_k" => Integer(top_k), "filter" => filter }
619
+ json(vector("Search", body, database), "search")["results"] || []
620
+ end
621
+
622
+ # @return [Array<String>]
623
+ def vector_list_collections(database: @database)
624
+ json(request({ "Vector" => "ListCollections" }, database: database), "listCollections")["collections"] || []
625
+ end
626
+
627
+ # @return [Hash] `"dimension"`, `"metric"`, `"count"`, `"quantization"`
628
+ def vector_describe_collection(collection, database: @database)
629
+ json(vector("DescribeCollection", { "collection" => collection }, database), "describeCollection")
630
+ end
631
+
632
+ # @return [Hash] `"vectors"`, `"count"`, `"total"`, `"truncated"`
633
+ def vector_list_vectors(collection, limit: nil, offset: nil, database: @database)
634
+ json(vector("ListVectors", { "collection" => collection, "limit" => limit, "offset" => offset }, database), "listVectors")
635
+ end
636
+
637
+ def vector_drop_collection(collection, database: @database)
638
+ vector("DropCollection", { "collection" => collection }, database)
639
+ nil
640
+ end
641
+
642
+ # ---- graph -----------------------------------------------------------------
643
+ #
644
+ # `direction` is `outgoing` (the default), `incoming` or `both`.
645
+
646
+ def graph_create(graph, database: @database)
647
+ graph_op("CreateGraph", { "graph" => graph }, database)
648
+ nil
649
+ end
650
+
651
+ def graph_drop(graph, database: @database)
652
+ graph_op("DropGraph", { "graph" => graph }, database)
653
+ nil
654
+ end
655
+
656
+ # @return [Array<String>]
657
+ def graph_list(database: @database)
658
+ json(request({ "Graph" => "ListGraphs" }, database: database), "listGraphs")["graphs"] || []
659
+ end
660
+
661
+ # @return [String] the id
662
+ def graph_add_node(graph, id, labels: [], properties: {}, database: @database)
663
+ body = { "graph" => graph, "id" => id, "labels" => Array(labels), "properties" => properties || {} }
664
+ json(graph_op("AddNode", body, database), "addNode")["id"]
665
+ end
666
+
667
+ # @return [Hash, nil] `"id"`, `"labels"`, `"properties"`
668
+ def graph_get_node(graph, id, database: @database)
669
+ json(graph_op("GetNode", { "graph" => graph, "id" => id }, database), "getNode")
670
+ end
671
+
672
+ def graph_delete_node(graph, id, database: @database)
673
+ graph_op("DeleteNode", { "graph" => graph, "id" => id }, database)
674
+ nil
675
+ end
676
+
677
+ # @return [String] the id
678
+ def graph_add_edge(graph, id, from, to, label, properties: {}, database: @database)
679
+ body = { "graph" => graph, "id" => id, "from" => from, "to" => to, "label" => label, "properties" => properties || {} }
680
+ json(graph_op("AddEdge", body, database), "addEdge")["id"]
681
+ end
682
+
683
+ # @return [Hash, nil] `"id"`, `"from"`, `"to"`, `"label"`, `"properties"`
684
+ def graph_get_edge(graph, id, database: @database)
685
+ json(graph_op("GetEdge", { "graph" => graph, "id" => id }, database), "getEdge")
686
+ end
687
+
688
+ def graph_delete_edge(graph, id, database: @database)
689
+ graph_op("DeleteEdge", { "graph" => graph, "id" => id }, database)
690
+ nil
691
+ end
692
+
693
+ # @return [Array<Hash>] `"node_id"`, `"edge_id"`, `"label"`, `"direction"`
694
+ def graph_neighbors(graph, node_id, direction: "outgoing", label: nil, limit: nil, database: @database)
695
+ body = { "graph" => graph, "node_id" => node_id, "direction" => direction.to_s, "label" => label, "limit" => limit }
696
+ json(graph_op("Neighbors", body, database), "neighbors")["neighbors"] || []
697
+ end
698
+
699
+ # @return [Integer]
700
+ def graph_degree(graph, node_id, direction: "outgoing", database: @database)
701
+ body = { "graph" => graph, "node_id" => node_id, "direction" => direction.to_s }
702
+ json(graph_op("Degree", body, database), "degree")["degree"]
703
+ end
704
+
705
+ # Bounded breadth-first walk.
706
+ # @return [Hash] `"nodes"` (each with `"depth"`), `"count"`, `"truncated"`, `"max_depth"`, `"limit"`
707
+ def graph_traverse(graph, start, direction: "outgoing", label: nil, max_depth: nil, limit: nil, database: @database)
708
+ body = { "graph" => graph, "start" => start, "direction" => direction.to_s, "label" => label,
709
+ "max_depth" => max_depth, "limit" => limit }
710
+ json(graph_op("Traverse", body, database), "traverse")
711
+ end
712
+
713
+ # Fewest hops. "No path" is `"found" => false`, not an error.
714
+ # @return [Hash] `"found"`, `"hops"`, `"node_path"`, `"edge_path"`
715
+ def graph_shortest_path(graph, from, to, direction: "outgoing", label: nil, max_depth: nil, database: @database)
716
+ body = { "graph" => graph, "from" => from, "to" => to, "direction" => direction.to_s, "label" => label,
717
+ "max_depth" => max_depth }
718
+ json(graph_op("ShortestPath", body, database), "shortestPath")
719
+ end
720
+
721
+ # Least summed edge weight.
722
+ # @return [Hash] `"found"`, `"total_cost"`, `"node_path"`, `"edge_path"`
723
+ def graph_weighted_shortest_path(graph, from, to, direction: "outgoing", label: nil, weight_property: nil,
724
+ database: @database)
725
+ body = { "graph" => graph, "from" => from, "to" => to, "direction" => direction.to_s, "label" => label,
726
+ "weight_property" => weight_property }
727
+ json(graph_op("WeightedShortestPath", body, database), "weightedShortestPath")
728
+ end
729
+
730
+ # @return [Hash] `"nodes"`, `"count"`, `"total"`, `"truncated"`
731
+ def graph_list_nodes(graph, limit: nil, offset: nil, database: @database)
732
+ json(graph_op("ListNodes", { "graph" => graph, "limit" => limit, "offset" => offset }, database), "listNodes")
733
+ end
734
+
735
+ # @return [Hash] `"edges"`, `"count"`, `"total"`, `"truncated"`
736
+ def graph_list_edges(graph, limit: nil, offset: nil, database: @database)
737
+ json(graph_op("ListEdges", { "graph" => graph, "limit" => limit, "offset" => offset }, database), "listEdges")
738
+ end
739
+
740
+ # Read-only Cypher subset.
741
+ # @return [Hash] `"columns"`, `"rows"`, `"count"`, `"truncated"`
742
+ def graph_query(graph, cypher, database: @database)
743
+ json(graph_op("Query", { "graph" => graph, "cypher" => cypher }, database), "query")
744
+ end
745
+
746
+ # ---- llm ---------------------------------------------------------------------
747
+
748
+ # Export the schema catalogue.
749
+ # @param format [String] `toon`, `json`, `markdown` or `native`
750
+ # @return [String, Object] text for toon/markdown, a JSON value otherwise
751
+ def llm_schema(format: "toon", max_rows: nil, redact_sensitive: true, include_schema: false, database: @database)
752
+ body = { "format" => format.to_s, "options" => llm_options(max_rows, redact_sensitive, include_schema) }
753
+ rendered(request({ "Llm" => { "Schema" => body } }, database: database))
754
+ end
755
+
756
+ # Assemble a context bundle from read-only sources.
757
+ # @param sources [Array<Hash>] from {LlmSource}, or `{sql:}` / `{collection:}`
758
+ # @return [String, Object]
759
+ def llm_context(sources, format: "toon", max_rows: nil, redact_sensitive: true, include_schema: false,
760
+ database: @database)
761
+ wire = (sources.is_a?(Array) ? sources : [sources]).map { |s| LlmSource.coerce(s) }
762
+ raise ArgumentError, "a context bundle needs at least one source" if wire.empty?
763
+
764
+ body = { "sources" => wire, "format" => format.to_s,
765
+ "options" => llm_options(max_rows, redact_sensitive, include_schema) }
766
+ rendered(request({ "Llm" => { "Context" => body } }, database: database))
767
+ end
768
+
769
+ # ---- admin -------------------------------------------------------------------
770
+
771
+ # A request through the full pipeline (auth, routing, dispatch).
772
+ def admin_ping(database: @database)
773
+ request({ "Admin" => "Ping" }, database: database)
774
+ true
775
+ end
776
+
777
+ # @return [Hash]
778
+ def admin_status(database: @database)
779
+ resp = request({ "Admin" => "Status" }, database: database)
780
+ return { "message" => resp.arm("Message") } if resp.arm?("Message")
781
+
782
+ json(resp, "status")
783
+ end
784
+
785
+ # Holds the connection lock for one frame pair. Any failure between writing
786
+ # and finishing the read closes the connection: the stream position is unknown.
787
+ # @api private
788
+ def exchange(tag, payload, timeout: @read_timeout)
789
+ @lock.synchronize do
790
+ raise ConnectionError, "connection is closed" if closed?
791
+
792
+ bytes = Frame.encode(tag, payload)
793
+ done = false
794
+ begin
795
+ @transport.io.write(bytes)
796
+ result = @transport.read_frame(timeout)
797
+ done = true
798
+ result
799
+ rescue IOError, SystemCallError, OpenSSL::SSL::SSLError => e
800
+ raise ConnectionError, "socket error: #{e.message}"
801
+ ensure
802
+ unless done
803
+ @transport.close
804
+ @txn_open = false
805
+ end
806
+ end
807
+ end
808
+ end
809
+
810
+ private
811
+
812
+ def fail_stream(error)
813
+ @transport.close
814
+ @txn_open = false
815
+ raise error
816
+ end
817
+
818
+ def next_request_id
819
+ @lock.synchronize do
820
+ @rid += 1
821
+ @last_request_id = "#{@rid_prefix}-#{@rid}"
822
+ end
823
+ end
824
+
825
+ def require_feature(bit, name, what)
826
+ return if @granted_features & bit != 0
827
+
828
+ raise FeatureNotGranted.new(
829
+ name,
830
+ "the server did not grant #{name} in the handshake, so this connection cannot use #{what}"
831
+ )
832
+ end
833
+
834
+ def sql_body(sql, params)
835
+ raise ArgumentError, "sql must be a String" unless sql.is_a?(String)
836
+
837
+ body = { "sql" => sql }
838
+ return body if params.nil?
839
+
840
+ require_feature(Features::SERVER_PARAMS, "SERVER_PARAMS",
841
+ "server-side `?` parameters; this driver never interpolates values into SQL text")
842
+ encoded = Params.encode_all(params)
843
+ body["params"] = encoded unless encoded.empty?
844
+ body
845
+ end
846
+
847
+ def txn_control(keyword, database)
848
+ resp = begin
849
+ request({ "Sql" => { "Exec" => { "sql" => keyword } } }, database: database)
850
+ rescue FeatureNotGranted
851
+ raise
852
+ rescue Error
853
+ @txn_open = false unless keyword == "BEGIN"
854
+ raise
855
+ end
856
+ @txn_open = keyword == "BEGIN"
857
+ json(resp, keyword)
858
+ end
859
+
860
+ def cache(variant, body, database)
861
+ request({ "Cache" => { variant => body } }, database: database)
862
+ end
863
+
864
+ def document(variant, body, database)
865
+ request({ "Document" => { variant => body } }, database: database)
866
+ end
867
+
868
+ def vector(variant, body, database)
869
+ request({ "Vector" => { variant => body } }, database: database)
870
+ end
871
+
872
+ def graph_op(variant, body, database)
873
+ request({ "Graph" => { variant => body } }, database: database)
874
+ end
875
+
876
+ def nk(namespace, key)
877
+ { "namespace" => namespace.to_s, "key" => key.to_s }
878
+ end
879
+
880
+ def json(resp, what)
881
+ raise ProtocolError, "expected Json for #{what}, got #{resp.kind}" unless resp.arm?("Json")
882
+
883
+ resp.arm("Json")
884
+ end
885
+
886
+ def documents(resp, what)
887
+ raise ProtocolError, "expected Documents for #{what}, got #{resp.kind}" unless resp.arm?("Documents")
888
+
889
+ resp.arm("Documents") || []
890
+ end
891
+
892
+ def cache_value(resp, what)
893
+ raise ProtocolError, "expected CacheValue for #{what}, got #{resp.kind}" unless resp.arm?("CacheValue")
894
+
895
+ value = resp.arm("CacheValue")
896
+ value.nil? ? nil : value.pack("C*")
897
+ end
898
+
899
+ def rendered(resp)
900
+ return resp.arm("Toon") if resp.arm?("Toon")
901
+ return resp.arm("Json") if resp.arm?("Json")
902
+ return resp.arm("Message") if resp.arm?("Message")
903
+
904
+ raise ProtocolError, "expected a rendered export, got #{resp.kind}"
905
+ end
906
+
907
+ def bytes(value, name)
908
+ case value
909
+ when String then value.bytes
910
+ when Binary then value.bytes.bytes
911
+ when Array
912
+ unless value.all? { |b| b.is_a?(Integer) && b.between?(0, 255) }
913
+ raise ArgumentError, "#{name} as an Array must hold Integers 0..255"
914
+ end
915
+
916
+ value
917
+ else
918
+ raise ArgumentError, "#{name} must be a String, TriCoreDB::Binary or Array of bytes, got #{value.class}"
919
+ end
920
+ end
921
+
922
+ def byte_list(values, name)
923
+ raise ArgumentError, "#{name} must be a non-empty Array" unless values.is_a?(Array) && !values.empty?
924
+
925
+ values.map { |v| bytes(v, "#{name} element") }
926
+ end
927
+
928
+ def pairs(entries, name)
929
+ list = entries.is_a?(Hash) ? entries.to_a : entries
930
+ raise ArgumentError, "#{name} must be a non-empty Hash or Array of pairs" unless list.is_a?(Array) && !list.empty?
931
+
932
+ list.map do |pair|
933
+ raise ArgumentError, "each #{name} entry must be a [field, value] pair" unless pair.is_a?(Array) && pair.size == 2
934
+
935
+ [bytes(pair[0].is_a?(Symbol) ? pair[0].to_s : pair[0], "#{name} field"), bytes(pair[1], "#{name} value")]
936
+ end
937
+ end
938
+
939
+ def binaries(values)
940
+ (values || []).map { |v| v.pack("C*") }
941
+ end
942
+
943
+ def stream_entries(json)
944
+ (json["entries"] || []).map do |e|
945
+ StreamEntry.new(e["id"], (e["fields"] || []).map { |f, v| [f.pack("C*"), v.pack("C*")] })
946
+ end
947
+ end
948
+
949
+ def numbers(values)
950
+ list = values.to_a
951
+ list.each do |v|
952
+ raise ArgumentError, "vector components must be finite numbers" unless v.is_a?(Numeric) && v.to_f.finite?
953
+ end
954
+ list
955
+ end
956
+
957
+ def update_body(set, inc)
958
+ body = {}
959
+ body["set"] = set if set && !set.empty?
960
+ body["inc"] = inc if inc && !inc.empty?
961
+ body
962
+ end
963
+
964
+ def llm_options(max_rows, redact_sensitive, include_schema)
965
+ { "max_rows" => max_rows, "redact_sensitive" => redact_sensitive ? true : false,
966
+ "include_schema" => include_schema ? true : false }
967
+ end
968
+
969
+ def error_text(body)
970
+ return body["message"] || body["error"] || JSON.generate(body) if body.is_a?(Hash)
971
+
972
+ body.to_s
973
+ end
974
+
975
+ def error_code(body)
976
+ body.is_a?(Hash) && body["code"].is_a?(String) && !body["code"].empty? ? body["code"] : nil
977
+ end
978
+ end
979
+ end