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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +46 -0
- data/LICENSE +201 -0
- data/README.md +377 -0
- data/lib/tricoredb/builders.rb +142 -0
- data/lib/tricoredb/client.rb +979 -0
- data/lib/tricoredb/errors.rb +99 -0
- data/lib/tricoredb/frame.rb +106 -0
- data/lib/tricoredb/params.rb +121 -0
- data/lib/tricoredb/pool.rb +159 -0
- data/lib/tricoredb/response.rb +131 -0
- data/lib/tricoredb/transport.rb +184 -0
- data/lib/tricoredb/version.rb +6 -0
- data/lib/tricoredb.rb +21 -0
- data/tricoredb.gemspec +30 -0
- metadata +92 -0
|
@@ -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
|