tina4-ultipa 0.1.0 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 78beafa9836b0d709d9a02d788ca1bd25bed6e75a8d72b59c28fd07f777ea566
4
- data.tar.gz: f367219bedd06967311975326649f6507bace52a439147866bb4144eb96289e1
3
+ metadata.gz: db28f1b774ee4b89511ce92be09703eafcee240f8d365434b65b33aa7b68ac15
4
+ data.tar.gz: 1a35099dd84422f8047c1f3cb5867c1083c6a454635a83019e7f5bc87b71059a
5
5
  SHA512:
6
- metadata.gz: 07db5593de9c6a3e6ece49e205263f962ddbafb00b07831a592999c81e5873057b13f8a7b927c60b166138ad326e412cf84239bda016b7876b1d8d56de7bc850
7
- data.tar.gz: 22ef4d3cf031368ec8524c082178eb178614cd218b43773938df54f34efa9e25955177bccdd7d7712dcf8563ca8349ba2bc0afebb289b8cf06f8b4ed61b59ea9
6
+ metadata.gz: 57a742e80ccc65b03794cf907ac37dc3cd5d723fcb18a37c2e25cb1375d57a327a17ab949e54e6de73f612ed9de61c01a240b6bb1f55b9230998a9d39ae43f36
7
+ data.tar.gz: 41b7a417fbe6a8eaceb481a26f7cc556cea14fba09025ff94419a31c36f3e62fe5544ef7d6d752d5142c6937851e9c2784090672139c39d2b58f46584f341190
@@ -6,17 +6,16 @@
6
6
  # then that id rides every call as the gRPC metadata header `session-id`.
7
7
  # Query: QueryService.Gql(GqlRequest) -> GqlResponse. This is the whole surface the
8
8
  # Tina4 graph adapter needs; node/edge/traverse are built in GQL on top of query.
9
+ #
10
+ # Wire stack (pure Ruby, no C extensions, no google-protobuf):
11
+ # * `http-2` gem drives HTTP/2 framing (HPACK, streams, flow control)
12
+ # * lib/tina4_ultipa/grpc.rb wraps that in gRPC framing (unary_unary only)
13
+ # * lib/tina4_ultipa/pb.rb hand-rolls proto3 for the 8 gqldb messages
9
14
 
10
15
  require "uri"
11
- require "grpc"
12
-
13
- # The generated stubs `require 'gqldb_pb'` / `require 'gqldb_services_pb'` by bare
14
- # name, so put their directory on the load path before requiring them.
15
- _gen_dir = File.expand_path("gen", __dir__)
16
- $LOAD_PATH.unshift(_gen_dir) unless $LOAD_PATH.include?(_gen_dir)
17
- require "gqldb_pb"
18
- require "gqldb_services_pb"
19
16
 
17
+ require_relative "pb"
18
+ require_relative "grpc"
20
19
  require_relative "codec"
21
20
  require_relative "errors"
22
21
 
@@ -38,14 +37,9 @@ module Tina4Ultipa
38
37
  @warnings = resp.warnings.to_a
39
38
  @current_graph = resp.current_graph
40
39
  d = resp.dml_stats
41
- @dml_stats = if d
42
- { inserted_nodes: d.inserted_nodes, inserted_edges: d.inserted_edges,
43
- deleted_nodes: d.deleted_nodes, deleted_edges: d.deleted_edges,
44
- set_nodes: d.set_nodes, set_edges: d.set_edges }
45
- else
46
- { inserted_nodes: 0, inserted_edges: 0, deleted_nodes: 0,
47
- deleted_edges: 0, set_nodes: 0, set_edges: 0 }
48
- end
40
+ @dml_stats = { inserted_nodes: d.inserted_nodes, inserted_edges: d.inserted_edges,
41
+ deleted_nodes: d.deleted_nodes, deleted_edges: d.deleted_edges,
42
+ set_nodes: d.set_nodes, set_edges: d.set_edges }
49
43
  end
50
44
 
51
45
  # Rows as hashes keyed by column name.
@@ -85,8 +79,6 @@ module Tina4Ultipa
85
79
  @connect_timeout = connect_timeout.to_f
86
80
  @use_tls = use_tls
87
81
  @channel = nil
88
- @session = nil
89
- @query = nil
90
82
  @session_id = nil
91
83
  @server_version = nil
92
84
  end
@@ -112,25 +104,38 @@ module Tina4Ultipa
112
104
  def connect
113
105
  return self unless @session_id.nil?
114
106
 
115
- target = "#{@host}:#{@port}"
116
- creds = @use_tls ? GRPC::Core::ChannelCredentials.new : :this_channel_is_insecure
117
- @channel = GRPC::Core::Channel.new(target, nil, creds)
107
+ @channel = GrpcClient::Channel.new(
108
+ @host, @port, use_tls: @use_tls, connect_timeout: @connect_timeout,
109
+ )
118
110
  started = monotonic
119
- wait_for_ready(@channel, started)
111
+ begin
112
+ @channel.connect
113
+ rescue GrpcClient::ConnectTimeout => e
114
+ elapsed = monotonic - started
115
+ close
116
+ raise ConnectError.new(@host, @port, elapsed, cause: e)
117
+ end
120
118
 
121
- @session = Gqldb::SessionService::Stub.new(target, creds, channel_override: @channel)
122
- @query = Gqldb::QueryService::Stub.new(target, creds, channel_override: @channel)
119
+ req = Gqldb::LoginRequest.new(
120
+ username: @username || "", password: @password || "",
121
+ default_graph: @graph,
122
+ )
123
123
  begin
124
- resp = @session.login(
125
- Gqldb::LoginRequest.new(
126
- username: @username || "", password: @password || "", default_graph: @graph,
127
- ),
128
- deadline: Time.now + @connect_timeout,
124
+ resp_bytes = @channel.unary(
125
+ "/gqldb.SessionService/Login",
126
+ Gqldb::LoginRequest.encode(req),
127
+ timeout: @connect_timeout,
129
128
  )
130
- rescue GRPC::BadStatus => e
129
+ rescue GrpcClient::RpcError => e
131
130
  close
132
131
  raise wrap(e, "login failed")
132
+ rescue GrpcClient::ConnectTimeout => e
133
+ elapsed = monotonic - started
134
+ close
135
+ raise ConnectError.new(@host, @port, elapsed, cause: e)
133
136
  end
137
+
138
+ resp = Gqldb::LoginResponse.decode(resp_bytes)
134
139
  @session_id = resp.session_id
135
140
  @server_version = resp.server_version
136
141
  self
@@ -152,11 +157,16 @@ module Tina4Ultipa
152
157
  timeout: timeout ? timeout.to_i : 0,
153
158
  )
154
159
  begin
155
- resp = @query.gql(req, metadata: { "session-id" => @session_id.to_s })
156
- rescue GRPC::BadStatus => e
160
+ resp_bytes = @channel.unary(
161
+ "/gqldb.QueryService/Gql",
162
+ Gqldb::GqlRequest.encode(req),
163
+ metadata: { "session-id" => @session_id.to_s },
164
+ timeout: timeout ? timeout.to_f : nil,
165
+ )
166
+ rescue GrpcClient::RpcError => e
157
167
  raise wrap(e, "gql failed")
158
168
  end
159
- Result.new(resp)
169
+ Result.new(Gqldb::GqlResponse.decode(resp_bytes))
160
170
  end
161
171
 
162
172
  # Run a writing GQL statement (read_only = false).
@@ -165,11 +175,12 @@ module Tina4Ultipa
165
175
  end
166
176
 
167
177
  def close
168
- @channel.close if @channel.respond_to?(:close)
178
+ @channel&.close
169
179
  rescue StandardError
170
180
  # best effort - the channel is being torn down regardless
171
181
  ensure
172
- @channel = @session = @query = @session_id = nil
182
+ @channel = nil
183
+ @session_id = nil
173
184
  end
174
185
 
175
186
  private
@@ -178,28 +189,9 @@ module Tina4Ultipa
178
189
  Process.clock_gettime(Process::CLOCK_MONOTONIC)
179
190
  end
180
191
 
181
- def wait_for_ready(channel, started)
182
- states = GRPC::Core::ConnectivityStates
183
- deadline = Time.now + @connect_timeout
184
- state = channel.connectivity_state(true)
185
- until state == states::READY
186
- break if Time.now >= deadline
187
-
188
- channel.watch_connectivity_state(state, deadline)
189
- state = channel.connectivity_state(true)
190
- end
191
- return if state == states::READY
192
-
193
- elapsed = monotonic - started
194
- close
195
- raise ConnectError.new(@host, @port, elapsed)
196
- end
197
-
198
192
  def wrap(rpc_error, prefix)
199
- detail = rpc_error.respond_to?(:details) ? rpc_error.details : nil
200
- detail = rpc_error.message if detail.nil? || detail.to_s.empty?
201
- code = rpc_error.class.name.to_s.split("::").last
202
- Error.new("#{prefix}: #{detail}", code: code, cause: rpc_error)
193
+ code = rpc_error.respond_to?(:status_name) ? rpc_error.status_name : nil
194
+ Error.new("#{prefix}: #{rpc_error.message}", code: code, cause: rpc_error)
203
195
  end
204
196
  end
205
197
  end
@@ -2,6 +2,8 @@
2
2
 
3
3
  require "json"
4
4
 
5
+ require_relative "pb"
6
+
5
7
  # Encode/decode gqldb TypedValue <-> Ruby values.
6
8
  #
7
9
  # Grounded against the official gqldb-nodejs SDK decoder and verified live against
@@ -0,0 +1,226 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+ require "openssl"
5
+ require "http/2"
6
+
7
+ # Minimal gRPC-over-HTTP/2 client for this driver's 2 unary RPCs.
8
+ #
9
+ # We ride the pure-Ruby `http-2` gem (no C extensions, no google-protobuf
10
+ # transitive dep) and wrap it in the gRPC framing convention:
11
+ #
12
+ # +--------+------------+---------+
13
+ # | 0x00 | u32 BE len | payload | (compressed flag = 0 = uncompressed)
14
+ # +--------+------------+---------+
15
+ #
16
+ # The response comes back the same way, with the RPC outcome in HTTP/2
17
+ # trailers as `grpc-status: N` and optionally `grpc-message: text`.
18
+ #
19
+ # Only unary_unary is supported - streaming RPCs (server/client/bidi) are not
20
+ # used by gqldb's Login/Gql surface. Adding them is a self-contained extension.
21
+
22
+ module Tina4Ultipa
23
+ module GrpcClient
24
+ # Raised by Channel#unary when the peer returns a non-OK grpc-status trailer.
25
+ class RpcError < StandardError
26
+ attr_reader :status_code, :status_name
27
+
28
+ GRPC_STATUS_NAMES = {
29
+ 0 => "OK", 1 => "CANCELLED", 2 => "UNKNOWN", 3 => "INVALID_ARGUMENT",
30
+ 4 => "DEADLINE_EXCEEDED", 5 => "NOT_FOUND", 6 => "ALREADY_EXISTS",
31
+ 7 => "PERMISSION_DENIED", 8 => "RESOURCE_EXHAUSTED", 9 => "FAILED_PRECONDITION",
32
+ 10 => "ABORTED", 11 => "OUT_OF_RANGE", 12 => "UNIMPLEMENTED",
33
+ 13 => "INTERNAL", 14 => "UNAVAILABLE", 15 => "DATA_LOSS",
34
+ 16 => "UNAUTHENTICATED",
35
+ }.freeze
36
+
37
+ def initialize(status_code, message)
38
+ @status_code = status_code
39
+ @status_name = GRPC_STATUS_NAMES[status_code] || "STATUS_#{status_code}"
40
+ super("gRPC #{@status_name}: #{message}")
41
+ end
42
+ end
43
+
44
+ # Raised when the socket connect / TLS handshake times out. Callers translate
45
+ # this into their own ConnectError with the elapsed time.
46
+ class ConnectTimeout < StandardError; end
47
+
48
+ # A single HTTP/2 connection to a gqldb host. Not thread-safe; use one
49
+ # Channel per Client instance.
50
+ class Channel
51
+ READ_CHUNK = 16_384
52
+
53
+ def initialize(host, port, use_tls: false, connect_timeout: 10.0)
54
+ @host = host
55
+ @port = port.to_i
56
+ @use_tls = use_tls
57
+ @connect_timeout = connect_timeout.to_f
58
+ @socket = nil
59
+ @client = nil
60
+ @closed = false
61
+ end
62
+
63
+ def connect
64
+ return self if @socket
65
+
66
+ deadline = monotonic + @connect_timeout
67
+
68
+ # Plain TCP connect with a wall-clock deadline. Socket.tcp handles
69
+ # IPv4/IPv6 fallback; a bare TCPSocket does not.
70
+ begin
71
+ @socket = Socket.tcp(@host, @port, connect_timeout: @connect_timeout)
72
+ rescue Errno::ETIMEDOUT, Errno::ECONNREFUSED, Errno::EHOSTUNREACH,
73
+ Errno::ENETUNREACH, SocketError => e
74
+ raise ConnectTimeout, "tcp: #{e.class}: #{e.message}"
75
+ end
76
+
77
+ if @use_tls
78
+ ctx = OpenSSL::SSL::SSLContext.new
79
+ ctx.alpn_protocols = ["h2"]
80
+ @socket = OpenSSL::SSL::SSLSocket.new(@socket, ctx)
81
+ @socket.sync_close = true
82
+ @socket.hostname = @host
83
+ begin
84
+ @socket.connect
85
+ rescue OpenSSL::SSL::SSLError => e
86
+ raise ConnectTimeout, "tls: #{e.message}"
87
+ end
88
+ if @socket.respond_to?(:alpn_protocol) && @socket.alpn_protocol != "h2"
89
+ raise ConnectTimeout, "alpn: server did not select h2"
90
+ end
91
+ end
92
+
93
+ # http-2's Client emits :frame events with encoded HTTP/2 frames.
94
+ # Write them straight to the socket. All inbound bytes get fed back
95
+ # via `client << data`, which raises the state-machine events on
96
+ # the stream we open. The client preface + initial SETTINGS fires
97
+ # automatically the first time we emit a frame in `unary`, so there
98
+ # is nothing to do here beyond having a live socket + a Client
99
+ # instance ready to receive callbacks.
100
+ @client = HTTP2::Client.new
101
+ @client.on(:frame) { |bytes| @socket.write(bytes) }
102
+ self
103
+ end
104
+
105
+ # unary(method, payload_bytes, metadata:, timeout:) -> response payload bytes
106
+ #
107
+ # Raises RpcError on non-OK grpc-status, ConnectTimeout on connect/read
108
+ # deadline overrun.
109
+ def unary(method, payload, metadata: {}, timeout: nil)
110
+ connect
111
+ deadline = timeout ? monotonic + timeout.to_f : nil
112
+
113
+ headers = {
114
+ ":method" => "POST",
115
+ ":scheme" => @use_tls ? "https" : "http",
116
+ ":path" => method,
117
+ ":authority" => "#{@host}:#{@port}",
118
+ "content-type" => "application/grpc+proto",
119
+ "te" => "trailers",
120
+ "user-agent" => "tina4-ultipa-ruby/0.2 http-2",
121
+ }
122
+ metadata.each { |k, v| headers[k.to_s.downcase] = v.to_s }
123
+
124
+ stream = @client.new_stream
125
+
126
+ resp_status = nil
127
+ resp_message = nil
128
+ resp_body = "".b
129
+ stream_closed = false
130
+ rst_reason = nil
131
+
132
+ stream.on(:headers) do |h|
133
+ h.each do |k, v|
134
+ case k
135
+ when "grpc-status" then resp_status = v.to_i
136
+ when "grpc-message" then resp_message = v
137
+ end
138
+ end
139
+ end
140
+ stream.on(:data) { |d| resp_body << d.b }
141
+ stream.on(:close) { stream_closed = true }
142
+ stream.on(:reset) { |code| rst_reason = code; stream_closed = true }
143
+
144
+ stream.headers(headers, end_stream: false)
145
+ stream.data(frame_encode(payload), end_stream: true)
146
+
147
+ drain_until(deadline) { stream_closed }
148
+
149
+ if rst_reason
150
+ raise RpcError.new(2, "stream reset (#{rst_reason})")
151
+ end
152
+ # No grpc-status trailer at all is a protocol violation (or the server
153
+ # closed the connection); surface as UNKNOWN so the caller sees why.
154
+ if resp_status.nil?
155
+ raise RpcError.new(2, "no grpc-status trailer (server may have closed)")
156
+ end
157
+ unless resp_status.zero?
158
+ raise RpcError.new(resp_status, resp_message || "")
159
+ end
160
+
161
+ frame_decode(resp_body)
162
+ end
163
+
164
+ def close
165
+ return if @closed
166
+
167
+ @closed = true
168
+ @socket&.close rescue nil
169
+ @socket = nil
170
+ @client = nil
171
+ end
172
+
173
+ private
174
+
175
+ def monotonic
176
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
177
+ end
178
+
179
+ # Drive the read side until the block returns true or the deadline elapses.
180
+ def drain_until(deadline)
181
+ loop do
182
+ return if yield
183
+
184
+ remaining = deadline ? deadline - monotonic : nil
185
+ raise ConnectTimeout, "read deadline" if remaining && remaining <= 0
186
+
187
+ ready = IO.select([@socket], nil, nil, remaining)
188
+ raise ConnectTimeout, "read deadline" if ready.nil?
189
+
190
+ chunk = read_available
191
+ break if chunk.nil? || chunk.empty?
192
+
193
+ @client << chunk
194
+ end
195
+ end
196
+
197
+ def read_available
198
+ # SSLSocket buffers ciphertext internally; readpartial returns whatever
199
+ # plaintext is available. On plain TCP, readpartial blocks until any
200
+ # byte arrives (we already selected, so at least one is ready).
201
+ @socket.readpartial(READ_CHUNK)
202
+ rescue EOFError
203
+ nil
204
+ end
205
+
206
+ # gRPC frame = 1 byte flag (0 = uncompressed) + u32 BE length + payload.
207
+ def frame_encode(payload)
208
+ "\x00".b + [payload.bytesize].pack("N") + payload.b
209
+ end
210
+
211
+ # Decode ONE gRPC frame. Unary responses ship exactly one frame; if the
212
+ # server ever streams multiple frames back, extend this to loop.
213
+ def frame_decode(bytes)
214
+ raise RpcError.new(2, "short gRPC frame (#{bytes.bytesize} bytes)") if bytes.bytesize < 5
215
+
216
+ _flag = bytes.getbyte(0)
217
+ len = bytes.byteslice(1, 4).unpack1("N")
218
+ if bytes.bytesize < 5 + len
219
+ raise RpcError.new(2, "truncated gRPC frame (want #{5 + len}, got #{bytes.bytesize})")
220
+ end
221
+
222
+ bytes.byteslice(5, len).b
223
+ end
224
+ end
225
+ end
226
+ end
@@ -0,0 +1,426 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Hand-rolled proto3 wire codec for the gqldb messages this driver uses.
4
+ #
5
+ # The `grpc` gem calls MessageClass.encode(msg) and MessageClass.decode(bytes)
6
+ # on whatever class we register with the stub. We supply those two class
7
+ # methods so grpc has no idea protobuf isn't in the loop. No google-protobuf
8
+ # runtime, no descriptor pool, no generated stubs.
9
+ #
10
+ # What we speak:
11
+ # Envelopes: LoginRequest / LoginResponse / GqlRequest / GqlResponse
12
+ # Nested: Parameter / TypedValue / Row / DmlStats
13
+ # Enum: PropertyType (integer constants + symbol lookup for codec parity)
14
+ #
15
+ # Proto3 rules we honour:
16
+ # * default scalar values are NOT written to the wire
17
+ # * unknown fields on read are skipped (server may add fields; we ignore them)
18
+ # * repeated scalar strings/bytes/messages are NOT packed (one tag per element)
19
+ # * repeated numeric scalars would be packed - this schema has none
20
+ #
21
+ # NOT implemented (gqldb.proto does not use them; adding any is a self-contained
22
+ # ~15-line extension):
23
+ # oneof, map<>, Any, packed scalars, groups, edition-2023 features.
24
+
25
+ module Gqldb
26
+ # PropertyType. Integer constants + symbol<->int lookups. Codec speaks symbols,
27
+ # wire speaks integers.
28
+ module PropertyType
29
+ NAMES = {
30
+ 0 => :PROPERTY_TYPE_UNSET,
31
+ 1 => :PROPERTY_TYPE_INT32,
32
+ 2 => :PROPERTY_TYPE_UINT32,
33
+ 3 => :PROPERTY_TYPE_INT64,
34
+ 4 => :PROPERTY_TYPE_UINT64,
35
+ 5 => :PROPERTY_TYPE_FLOAT,
36
+ 6 => :PROPERTY_TYPE_DOUBLE,
37
+ 7 => :PROPERTY_TYPE_STRING,
38
+ 8 => :PROPERTY_TYPE_DATETIME,
39
+ 9 => :PROPERTY_TYPE_TIMESTAMP,
40
+ 10 => :PROPERTY_TYPE_TEXT,
41
+ 11 => :PROPERTY_TYPE_BLOB,
42
+ 12 => :PROPERTY_TYPE_POINT,
43
+ 13 => :PROPERTY_TYPE_DECIMAL,
44
+ 14 => :PROPERTY_TYPE_LIST,
45
+ 15 => :PROPERTY_TYPE_SET,
46
+ 16 => :PROPERTY_TYPE_MAP,
47
+ 17 => :PROPERTY_TYPE_NULL,
48
+ 18 => :PROPERTY_TYPE_BOOL,
49
+ 19 => :PROPERTY_TYPE_LOCAL_DATETIME,
50
+ 20 => :PROPERTY_TYPE_ZONED_DATETIME,
51
+ 21 => :PROPERTY_TYPE_DATE,
52
+ 22 => :PROPERTY_TYPE_ZONED_TIME,
53
+ 23 => :PROPERTY_TYPE_LOCAL_TIME,
54
+ 24 => :PROPERTY_TYPE_YEAR_TO_MONTH,
55
+ 25 => :PROPERTY_TYPE_DAY_TO_SECOND,
56
+ 26 => :PROPERTY_TYPE_RECORD,
57
+ 27 => :PROPERTY_TYPE_POINT3D,
58
+ 28 => :PROPERTY_TYPE_VECTOR,
59
+ 29 => :PROPERTY_TYPE_TABLE,
60
+ 30 => :PROPERTY_TYPE_PATH,
61
+ 31 => :PROPERTY_TYPE_ERROR,
62
+ 32 => :PROPERTY_TYPE_NODE,
63
+ 33 => :PROPERTY_TYPE_EDGE,
64
+ }.freeze
65
+ IDS = NAMES.invert.freeze
66
+
67
+ def self.id_of(sym_or_int)
68
+ return sym_or_int if sym_or_int.is_a?(Integer)
69
+ IDS[sym_or_int] || 0
70
+ end
71
+
72
+ def self.name_of(int_or_sym)
73
+ return int_or_sym if int_or_sym.is_a?(Symbol)
74
+ NAMES[int_or_sym] || int_or_sym
75
+ end
76
+ end
77
+
78
+ # ---- wire encoders --------------------------------------------------------
79
+ module Wire
80
+ module_function
81
+
82
+ def varint(buf, n)
83
+ # Handle negative numbers as two's-complement uint64.
84
+ n = (1 << 64) + n if n.negative?
85
+ while n >= 0x80
86
+ buf << ((n & 0x7F) | 0x80)
87
+ n >>= 7
88
+ end
89
+ buf << (n & 0x7F)
90
+ end
91
+
92
+ def tag(buf, field_no, wire_type)
93
+ varint(buf, (field_no << 3) | wire_type)
94
+ end
95
+
96
+ def write_bytes(buf, field_no, bytes)
97
+ return if bytes.nil? || bytes.empty?
98
+ tag(buf, field_no, 2)
99
+ varint(buf, bytes.bytesize)
100
+ buf << bytes.b
101
+ end
102
+
103
+ def write_string(buf, field_no, str)
104
+ return if str.nil? || str.empty?
105
+ write_bytes(buf, field_no, str.dup.force_encoding("BINARY"))
106
+ end
107
+
108
+ def write_varint(buf, field_no, n)
109
+ return if n.zero?
110
+ tag(buf, field_no, 0)
111
+ varint(buf, n)
112
+ end
113
+
114
+ def write_bool(buf, field_no, b)
115
+ return unless b
116
+ tag(buf, field_no, 0)
117
+ varint(buf, 1)
118
+ end
119
+ end
120
+
121
+ # ---- wire reader ----------------------------------------------------------
122
+ class Reader
123
+ def initialize(bytes)
124
+ @b = bytes.dup.force_encoding("BINARY")
125
+ @p = 0
126
+ @end = @b.bytesize
127
+ end
128
+
129
+ def eof?
130
+ @p >= @end
131
+ end
132
+
133
+ def read_varint
134
+ r = 0
135
+ sh = 0
136
+ loop do
137
+ byte = @b.getbyte(@p)
138
+ @p += 1
139
+ r |= (byte & 0x7F) << sh
140
+ return r if (byte & 0x80).zero?
141
+ sh += 7
142
+ end
143
+ end
144
+
145
+ def read_signed_varint
146
+ v = read_varint
147
+ v -= (1 << 64) if v >= (1 << 63)
148
+ v
149
+ end
150
+
151
+ def read_len
152
+ n = read_varint
153
+ s = @b.byteslice(@p, n) || ""
154
+ @p += n
155
+ s.dup.force_encoding("BINARY")
156
+ end
157
+
158
+ def read_string
159
+ read_len.force_encoding("UTF-8")
160
+ end
161
+
162
+ def skip(wire_type)
163
+ case wire_type
164
+ when 0 then read_varint
165
+ when 2 then @p += read_varint
166
+ when 5 then @p += 4
167
+ when 1 then @p += 8
168
+ else raise ArgumentError, "unknown wire type #{wire_type}"
169
+ end
170
+ end
171
+ end
172
+
173
+ # ---- messages -------------------------------------------------------------
174
+
175
+ class TypedValue
176
+ attr_accessor :type, :data, :is_null
177
+
178
+ def initialize(type: 0, data: "".b, is_null: false)
179
+ @type = PropertyType.id_of(type)
180
+ @data = (data || "".b).dup.force_encoding("BINARY")
181
+ @is_null = is_null
182
+ end
183
+
184
+ def self.encode(msg)
185
+ buf = "".b
186
+ Wire.write_varint(buf, 1, msg.type)
187
+ Wire.write_bytes(buf, 2, msg.data)
188
+ Wire.write_bool(buf, 3, msg.is_null)
189
+ buf
190
+ end
191
+
192
+ def self.decode(bytes)
193
+ o = new
194
+ r = Reader.new(bytes)
195
+ until r.eof?
196
+ tag = r.read_varint
197
+ fn = tag >> 3
198
+ wt = tag & 7
199
+ if fn == 1 && wt == 0 then o.type = r.read_varint
200
+ elsif fn == 2 && wt == 2 then o.data = r.read_len
201
+ elsif fn == 3 && wt == 0 then o.is_null = !r.read_varint.zero?
202
+ else r.skip(wt)
203
+ end
204
+ end
205
+ o
206
+ end
207
+ end
208
+
209
+ class Parameter
210
+ attr_accessor :name, :value
211
+
212
+ def initialize(name: "", value: TypedValue.new)
213
+ @name = name.to_s
214
+ @value = value
215
+ end
216
+
217
+ def self.encode(msg)
218
+ buf = "".b
219
+ Wire.write_string(buf, 1, msg.name)
220
+ inner = TypedValue.encode(msg.value)
221
+ Wire.write_bytes(buf, 2, inner)
222
+ buf
223
+ end
224
+ end
225
+
226
+ class Row
227
+ attr_accessor :values
228
+
229
+ def initialize(values: [])
230
+ @values = values
231
+ end
232
+
233
+ def self.decode(bytes)
234
+ o = new
235
+ r = Reader.new(bytes)
236
+ until r.eof?
237
+ tag = r.read_varint
238
+ fn = tag >> 3
239
+ wt = tag & 7
240
+ if fn == 1 && wt == 2
241
+ o.values << TypedValue.decode(r.read_len)
242
+ else
243
+ r.skip(wt)
244
+ end
245
+ end
246
+ o
247
+ end
248
+ end
249
+
250
+ class DmlStats
251
+ attr_accessor :inserted_nodes, :inserted_edges, :deleted_nodes,
252
+ :deleted_edges, :set_nodes, :set_edges
253
+
254
+ def initialize(inserted_nodes: 0, inserted_edges: 0, deleted_nodes: 0,
255
+ deleted_edges: 0, set_nodes: 0, set_edges: 0)
256
+ @inserted_nodes = inserted_nodes
257
+ @inserted_edges = inserted_edges
258
+ @deleted_nodes = deleted_nodes
259
+ @deleted_edges = deleted_edges
260
+ @set_nodes = set_nodes
261
+ @set_edges = set_edges
262
+ end
263
+
264
+ def self.decode(bytes)
265
+ o = new
266
+ r = Reader.new(bytes)
267
+ until r.eof?
268
+ tag = r.read_varint
269
+ fn = tag >> 3
270
+ wt = tag & 7
271
+ (r.skip(wt); next) if wt != 0
272
+ v = r.read_signed_varint
273
+ case fn
274
+ when 1 then o.inserted_nodes = v
275
+ when 2 then o.inserted_edges = v
276
+ when 3 then o.deleted_nodes = v
277
+ when 4 then o.deleted_edges = v
278
+ when 5 then o.set_nodes = v
279
+ when 6 then o.set_edges = v
280
+ end
281
+ end
282
+ o
283
+ end
284
+ end
285
+
286
+ class LoginRequest
287
+ attr_accessor :username, :password, :default_graph
288
+
289
+ def initialize(username: "", password: "", default_graph: "")
290
+ @username = username
291
+ @password = password
292
+ @default_graph = default_graph
293
+ end
294
+
295
+ def self.encode(msg)
296
+ buf = "".b
297
+ Wire.write_string(buf, 1, msg.username)
298
+ Wire.write_string(buf, 2, msg.password)
299
+ Wire.write_string(buf, 3, msg.default_graph)
300
+ buf
301
+ end
302
+ end
303
+
304
+ class LoginResponse
305
+ attr_accessor :session_id, :server_version, :roles, :is_cluster,
306
+ :cluster_id, :partition_count, :capabilities,
307
+ :current_graph, :time_cost_ns, :disk_cost_ns, :compute_cost_ns
308
+
309
+ def initialize
310
+ @session_id = 0
311
+ @server_version = ""
312
+ @roles = []
313
+ @is_cluster = false
314
+ @cluster_id = ""
315
+ @partition_count = 0
316
+ @capabilities = []
317
+ @current_graph = ""
318
+ @time_cost_ns = 0
319
+ @disk_cost_ns = 0
320
+ @compute_cost_ns = 0
321
+ end
322
+
323
+ def self.decode(bytes)
324
+ o = new
325
+ r = Reader.new(bytes)
326
+ until r.eof?
327
+ tag = r.read_varint
328
+ fn = tag >> 3
329
+ wt = tag & 7
330
+ case [fn, wt]
331
+ when [1, 0] then o.session_id = r.read_varint
332
+ when [2, 2] then o.server_version = r.read_string
333
+ when [3, 2] then o.roles << r.read_string
334
+ when [10, 0] then o.is_cluster = !r.read_varint.zero?
335
+ when [11, 2] then o.cluster_id = r.read_string
336
+ when [12, 0] then o.partition_count = r.read_signed_varint
337
+ when [20, 2] then o.capabilities << r.read_string
338
+ when [21, 2] then o.current_graph = r.read_string
339
+ when [22, 0] then o.time_cost_ns = r.read_signed_varint
340
+ when [23, 0] then o.disk_cost_ns = r.read_signed_varint
341
+ when [24, 0] then o.compute_cost_ns = r.read_signed_varint
342
+ else r.skip(wt)
343
+ end
344
+ end
345
+ o
346
+ end
347
+ end
348
+
349
+ class GqlRequest
350
+ attr_accessor :gql, :graph_name, :parameters, :session_id,
351
+ :transaction_id, :timeout, :read_only, :max_path_results
352
+
353
+ def initialize(gql: "", graph_name: "", parameters: [], session_id: 0,
354
+ transaction_id: 0, timeout: 0, read_only: false,
355
+ max_path_results: 0)
356
+ @gql = gql
357
+ @graph_name = graph_name
358
+ @parameters = parameters
359
+ @session_id = session_id
360
+ @transaction_id = transaction_id
361
+ @timeout = timeout
362
+ @read_only = read_only
363
+ @max_path_results = max_path_results
364
+ end
365
+
366
+ def self.encode(msg)
367
+ buf = "".b
368
+ Wire.write_string(buf, 1, msg.gql)
369
+ Wire.write_string(buf, 2, msg.graph_name)
370
+ msg.parameters.each do |p|
371
+ Wire.write_bytes(buf, 3, Parameter.encode(p))
372
+ end
373
+ Wire.write_varint(buf, 4, msg.session_id)
374
+ Wire.write_varint(buf, 5, msg.transaction_id)
375
+ Wire.write_varint(buf, 6, msg.timeout)
376
+ Wire.write_bool(buf, 7, msg.read_only)
377
+ Wire.write_varint(buf, 8, msg.max_path_results)
378
+ buf
379
+ end
380
+ end
381
+
382
+ class GqlResponse
383
+ attr_accessor :columns, :rows, :row_count, :has_more, :warnings,
384
+ :rows_affected, :current_graph, :time_cost_ns,
385
+ :disk_cost_ns, :compute_cost_ns, :dml_stats
386
+
387
+ def initialize
388
+ @columns = []
389
+ @rows = []
390
+ @row_count = 0
391
+ @has_more = false
392
+ @warnings = []
393
+ @rows_affected = 0
394
+ @current_graph = ""
395
+ @time_cost_ns = 0
396
+ @disk_cost_ns = 0
397
+ @compute_cost_ns = 0
398
+ @dml_stats = DmlStats.new
399
+ end
400
+
401
+ def self.decode(bytes)
402
+ o = new
403
+ r = Reader.new(bytes)
404
+ until r.eof?
405
+ tag = r.read_varint
406
+ fn = tag >> 3
407
+ wt = tag & 7
408
+ case [fn, wt]
409
+ when [1, 2] then o.columns << r.read_string
410
+ when [2, 2] then o.rows << Row.decode(r.read_len)
411
+ when [3, 0] then o.row_count = r.read_signed_varint
412
+ when [4, 0] then o.has_more = !r.read_varint.zero?
413
+ when [5, 2] then o.warnings << r.read_string
414
+ when [6, 0] then o.rows_affected = r.read_signed_varint
415
+ when [7, 2] then o.current_graph = r.read_string
416
+ when [8, 0] then o.time_cost_ns = r.read_signed_varint
417
+ when [9, 0] then o.disk_cost_ns = r.read_signed_varint
418
+ when [10, 0] then o.compute_cost_ns = r.read_signed_varint
419
+ when [11, 2] then o.dml_stats = DmlStats.decode(r.read_len)
420
+ else r.skip(wt)
421
+ end
422
+ end
423
+ o
424
+ end
425
+ end
426
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Tina4Ultipa
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
data/tina4-ultipa.gemspec CHANGED
@@ -28,6 +28,9 @@ Gem::Specification.new do |spec|
28
28
  ]
29
29
  spec.require_paths = ["lib"]
30
30
 
31
- spec.add_dependency "google-protobuf", ">= 3.25"
32
- spec.add_dependency "grpc", ">= 1.60"
31
+ # Pure-Ruby HTTP/2. Zero C extensions, zero google-protobuf, zero
32
+ # transitive deps. See lib/tina4_ultipa/grpc.rb for the tiny gRPC framing
33
+ # layer on top of it, and lib/tina4_ultipa/pb.rb for the hand-rolled
34
+ # proto3 wire codec for the 8 gqldb messages we consume.
35
+ spec.add_dependency "http-2", ">= 1.2"
33
36
  end
metadata CHANGED
@@ -1,43 +1,29 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tina4-ultipa
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tina4 Stack
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-08-21 00:00:00.000000000 Z
11
+ date: 2026-08-22 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
- name: google-protobuf
14
+ name: http-2
15
15
  requirement: !ruby/object:Gem::Requirement
16
16
  requirements:
17
17
  - - ">="
18
18
  - !ruby/object:Gem::Version
19
- version: '3.25'
19
+ version: '1.2'
20
20
  type: :runtime
21
21
  prerelease: false
22
22
  version_requirements: !ruby/object:Gem::Requirement
23
23
  requirements:
24
24
  - - ">="
25
25
  - !ruby/object:Gem::Version
26
- version: '3.25'
27
- - !ruby/object:Gem::Dependency
28
- name: grpc
29
- requirement: !ruby/object:Gem::Requirement
30
- requirements:
31
- - - ">="
32
- - !ruby/object:Gem::Version
33
- version: '1.60'
34
- type: :runtime
35
- prerelease: false
36
- version_requirements: !ruby/object:Gem::Requirement
37
- requirements:
38
- - - ">="
39
- - !ruby/object:Gem::Version
40
- version: '1.60'
26
+ version: '1.2'
41
27
  description: A thin, standalone gRPC client for the Ultipa graph database (ultipa-gqldb)
42
28
  - the Ultipa driver behind Tina4's graph layer, usable on its own.
43
29
  email:
@@ -52,8 +38,8 @@ files:
52
38
  - lib/tina4_ultipa/client.rb
53
39
  - lib/tina4_ultipa/codec.rb
54
40
  - lib/tina4_ultipa/errors.rb
55
- - lib/tina4_ultipa/gen/gqldb_pb.rb
56
- - lib/tina4_ultipa/gen/gqldb_services_pb.rb
41
+ - lib/tina4_ultipa/grpc.rb
42
+ - lib/tina4_ultipa/pb.rb
57
43
  - lib/tina4_ultipa/version.rb
58
44
  - proto/gqldb.proto
59
45
  - tina4-ultipa.gemspec
@@ -1,23 +0,0 @@
1
- # frozen_string_literal: true
2
- # Generated by the protocol buffer compiler. DO NOT EDIT!
3
- # source: gqldb.proto
4
-
5
- require 'google/protobuf'
6
-
7
-
8
- descriptor_data = "\n\x0bgqldb.proto\x12\x05gqldb\"N\n\nTypedValue\x12!\n\x04type\x18\x01 \x01(\x0e\x32\x13.gqldb.PropertyType\x12\x0c\n\x04\x64\x61ta\x18\x02 \x01(\x0c\x12\x0f\n\x07is_null\x18\x03 \x01(\x08\";\n\tParameter\x12\x0c\n\x04name\x18\x01 \x01(\t\x12 \n\x05value\x18\x02 \x01(\x0b\x32\x11.gqldb.TypedValue\"(\n\x03Row\x12!\n\x06values\x18\x01 \x03(\x0b\x32\x11.gqldb.TypedValue\"\x8e\x01\n\x08\x44mlStats\x12\x16\n\x0einserted_nodes\x18\x01 \x01(\x03\x12\x16\n\x0einserted_edges\x18\x02 \x01(\x03\x12\x15\n\rdeleted_nodes\x18\x03 \x01(\x03\x12\x15\n\rdeleted_edges\x18\x04 \x01(\x03\x12\x11\n\tset_nodes\x18\x05 \x01(\x03\x12\x11\n\tset_edges\x18\x06 \x01(\x03\"I\n\x0cLoginRequest\x12\x10\n\x08username\x18\x01 \x01(\t\x12\x10\n\x08password\x18\x02 \x01(\t\x12\x15\n\rdefault_graph\x18\x03 \x01(\t\"\xfd\x01\n\rLoginResponse\x12\x12\n\nsession_id\x18\x01 \x01(\x04\x12\x16\n\x0eserver_version\x18\x02 \x01(\t\x12\r\n\x05roles\x18\x03 \x03(\t\x12\x12\n\nis_cluster\x18\n \x01(\x08\x12\x12\n\ncluster_id\x18\x0b \x01(\t\x12\x17\n\x0fpartition_count\x18\x0c \x01(\x05\x12\x14\n\x0c\x63\x61pabilities\x18\x14 \x03(\t\x12\x15\n\rcurrent_graph\x18\x15 \x01(\t\x12\x14\n\x0ctime_cost_ns\x18\x16 \x01(\x03\x12\x14\n\x0c\x64isk_cost_ns\x18\x17 \x01(\x03\x12\x17\n\x0f\x63ompute_cost_ns\x18\x18 \x01(\x03\"\xbd\x01\n\nGqlRequest\x12\x0b\n\x03gql\x18\x01 \x01(\t\x12\x12\n\ngraph_name\x18\x02 \x01(\t\x12$\n\nparameters\x18\x03 \x03(\x0b\x32\x10.gqldb.Parameter\x12\x12\n\nsession_id\x18\x04 \x01(\x04\x12\x16\n\x0etransaction_id\x18\x05 \x01(\x04\x12\x0f\n\x07timeout\x18\x06 \x01(\x05\x12\x11\n\tread_only\x18\x07 \x01(\x08\x12\x18\n\x10max_path_results\x18\x08 \x01(\x03\"\x86\x02\n\x0bGqlResponse\x12\x0f\n\x07\x63olumns\x18\x01 \x03(\t\x12\x18\n\x04rows\x18\x02 \x03(\x0b\x32\n.gqldb.Row\x12\x11\n\trow_count\x18\x03 \x01(\x03\x12\x10\n\x08has_more\x18\x04 \x01(\x08\x12\x10\n\x08warnings\x18\x05 \x03(\t\x12\x15\n\rrows_affected\x18\x06 \x01(\x03\x12\x15\n\rcurrent_graph\x18\x07 \x01(\t\x12\x14\n\x0ctime_cost_ns\x18\x08 \x01(\x03\x12\x14\n\x0c\x64isk_cost_ns\x18\t \x01(\x03\x12\x17\n\x0f\x63ompute_cost_ns\x18\n \x01(\x03\x12\"\n\tdml_stats\x18\x0b \x01(\x0b\x32\x0f.gqldb.DmlStats*\x90\x07\n\x0cPropertyType\x12\x17\n\x13PROPERTY_TYPE_UNSET\x10\x00\x12\x17\n\x13PROPERTY_TYPE_INT32\x10\x01\x12\x18\n\x14PROPERTY_TYPE_UINT32\x10\x02\x12\x17\n\x13PROPERTY_TYPE_INT64\x10\x03\x12\x18\n\x14PROPERTY_TYPE_UINT64\x10\x04\x12\x17\n\x13PROPERTY_TYPE_FLOAT\x10\x05\x12\x18\n\x14PROPERTY_TYPE_DOUBLE\x10\x06\x12\x18\n\x14PROPERTY_TYPE_STRING\x10\x07\x12\x1a\n\x16PROPERTY_TYPE_DATETIME\x10\x08\x12\x1b\n\x17PROPERTY_TYPE_TIMESTAMP\x10\t\x12\x16\n\x12PROPERTY_TYPE_TEXT\x10\n\x12\x16\n\x12PROPERTY_TYPE_BLOB\x10\x0b\x12\x17\n\x13PROPERTY_TYPE_POINT\x10\x0c\x12\x19\n\x15PROPERTY_TYPE_DECIMAL\x10\r\x12\x16\n\x12PROPERTY_TYPE_LIST\x10\x0e\x12\x15\n\x11PROPERTY_TYPE_SET\x10\x0f\x12\x15\n\x11PROPERTY_TYPE_MAP\x10\x10\x12\x16\n\x12PROPERTY_TYPE_NULL\x10\x11\x12\x16\n\x12PROPERTY_TYPE_BOOL\x10\x12\x12 \n\x1cPROPERTY_TYPE_LOCAL_DATETIME\x10\x13\x12 \n\x1cPROPERTY_TYPE_ZONED_DATETIME\x10\x14\x12\x16\n\x12PROPERTY_TYPE_DATE\x10\x15\x12\x1c\n\x18PROPERTY_TYPE_ZONED_TIME\x10\x16\x12\x1c\n\x18PROPERTY_TYPE_LOCAL_TIME\x10\x17\x12\x1f\n\x1bPROPERTY_TYPE_YEAR_TO_MONTH\x10\x18\x12\x1f\n\x1bPROPERTY_TYPE_DAY_TO_SECOND\x10\x19\x12\x18\n\x14PROPERTY_TYPE_RECORD\x10\x1a\x12\x19\n\x15PROPERTY_TYPE_POINT3D\x10\x1b\x12\x18\n\x14PROPERTY_TYPE_VECTOR\x10\x1c\x12\x17\n\x13PROPERTY_TYPE_TABLE\x10\x1d\x12\x16\n\x12PROPERTY_TYPE_PATH\x10\x1e\x12\x17\n\x13PROPERTY_TYPE_ERROR\x10\x1f\x12\x16\n\x12PROPERTY_TYPE_NODE\x10 \x12\x16\n\x12PROPERTY_TYPE_EDGE\x10!2D\n\x0eSessionService\x12\x32\n\x05Login\x12\x13.gqldb.LoginRequest\x1a\x14.gqldb.LoginResponse2<\n\x0cQueryService\x12,\n\x03Gql\x12\x11.gqldb.GqlRequest\x1a\x12.gqldb.GqlResponseb\x06proto3"
9
-
10
- pool = ::Google::Protobuf::DescriptorPool.generated_pool
11
- pool.add_serialized_file(descriptor_data)
12
-
13
- module Gqldb
14
- TypedValue = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("gqldb.TypedValue").msgclass
15
- Parameter = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("gqldb.Parameter").msgclass
16
- Row = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("gqldb.Row").msgclass
17
- DmlStats = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("gqldb.DmlStats").msgclass
18
- LoginRequest = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("gqldb.LoginRequest").msgclass
19
- LoginResponse = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("gqldb.LoginResponse").msgclass
20
- GqlRequest = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("gqldb.GqlRequest").msgclass
21
- GqlResponse = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("gqldb.GqlResponse").msgclass
22
- PropertyType = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("gqldb.PropertyType").enummodule
23
- end
@@ -1,36 +0,0 @@
1
- # Generated by the protocol buffer compiler. DO NOT EDIT!
2
- # Source: gqldb.proto for package 'gqldb'
3
-
4
- require 'grpc'
5
- require 'gqldb_pb'
6
-
7
- module Gqldb
8
- module SessionService
9
- class Service
10
-
11
- include ::GRPC::GenericService
12
-
13
- self.marshal_class_method = :encode
14
- self.unmarshal_class_method = :decode
15
- self.service_name = 'gqldb.SessionService'
16
-
17
- rpc :Login, ::Gqldb::LoginRequest, ::Gqldb::LoginResponse
18
- end
19
-
20
- Stub = Service.rpc_stub_class
21
- end
22
- module QueryService
23
- class Service
24
-
25
- include ::GRPC::GenericService
26
-
27
- self.marshal_class_method = :encode
28
- self.unmarshal_class_method = :decode
29
- self.service_name = 'gqldb.QueryService'
30
-
31
- rpc :Gql, ::Gqldb::GqlRequest, ::Gqldb::GqlResponse
32
- end
33
-
34
- Stub = Service.rpc_stub_class
35
- end
36
- end