bzync-nextsql 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/LICENSE +21 -0
- data/README.md +37 -0
- data/lib/nextsql/client.rb +595 -0
- data/lib/nextsql/cluster.rb +190 -0
- data/lib/nextsql/errors.rb +18 -0
- data/lib/nextsql/protocol.rb +1126 -0
- data/lib/nextsql/version.rb +5 -0
- data/lib/nextsql.rb +68 -0
- metadata +53 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 7f8ea5d62b319951e5324da7495acd2b082dc3c05945b9c7cd8810f81ab204ed
|
|
4
|
+
data.tar.gz: 773874cbee89f62cea0754249800a087f8de553e79a90c7f0d5d6e85026f0a39
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 4c6cd6f4f39cd9aceedaaf0a373e8deedbe7a217ea9fdd0e04231bfc28409eeceb8cd84294b776ec1a48d8621fdf797e5007bc4203ff9bec950d95799333c128
|
|
7
|
+
data.tar.gz: 302b1dcfc03a25f1ac6ef126d99c9a1cfcb0c2c04dbf44414f8e4558e20b9029c8c0c5f2153f4f0de4326ff8ccb421944970ddb25412822d6cfc01887296050b
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Bzync Software Development Services
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# bzync-nextsql
|
|
2
|
+
|
|
3
|
+
Official [NextSQL](https://nextsql.bzync.com) driver for Ruby 3.0+. Speaks the native
|
|
4
|
+
NSQL v1 wire protocol over TLS 1.3. Pure standard library — no runtime gems.
|
|
5
|
+
|
|
6
|
+
Encryption keys and passwords are **never** accepted in a connection URL.
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
gem install bzync-nextsql
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
```ruby
|
|
13
|
+
require "nextsql"
|
|
14
|
+
|
|
15
|
+
conn = NextSQL.connect(NextSQL::Config.new(
|
|
16
|
+
address: "db.example.com:7210",
|
|
17
|
+
database: "production",
|
|
18
|
+
user: "app",
|
|
19
|
+
password: ENV["NEXTSQL_DATABASE_PASS"],
|
|
20
|
+
tls: NextSQL::TLSConfig.new(cafile: "/etc/nextsql/ca.pem", server_name: "db.example.com"),
|
|
21
|
+
))
|
|
22
|
+
|
|
23
|
+
begin
|
|
24
|
+
result = conn.exec("SELECT id, name FROM users WHERE id = $1", [1])
|
|
25
|
+
result.rows.each { |row| puts row.inspect }
|
|
26
|
+
ensure
|
|
27
|
+
conn.close
|
|
28
|
+
end
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Plaintext connections are allowed only on loopback. For an HA cluster with
|
|
32
|
+
follower-read routing, use `NextSQL.connect_cluster`.
|
|
33
|
+
|
|
34
|
+
- Full driver docs: <https://nextsql.bzync.com/docs/drivers>
|
|
35
|
+
- Wire protocol: <https://github.com/bzync/nextsql/blob/master/docs/protocol.md>
|
|
36
|
+
|
|
37
|
+
MIT licensed.
|
|
@@ -0,0 +1,595 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Official NextSQL Ruby driver. Speaks the native NSQL v1 protocol.
|
|
4
|
+
#
|
|
5
|
+
# Encryption keys and passwords are never accepted in a URL.
|
|
6
|
+
|
|
7
|
+
require "socket"
|
|
8
|
+
require "openssl"
|
|
9
|
+
|
|
10
|
+
require_relative "protocol"
|
|
11
|
+
require_relative "errors"
|
|
12
|
+
|
|
13
|
+
module NextSQL
|
|
14
|
+
# TLS options for a remote connection. +ca+ is PEM text/bytes; omit both
|
|
15
|
+
# +ca+ and +cafile+ to use the system trust store. Set
|
|
16
|
+
# +reject_unauthorized: false+ only for local testing against a
|
|
17
|
+
# self-signed certificate — never in production.
|
|
18
|
+
TLSConfig = Struct.new(:ca, :cafile, :server_name, :reject_unauthorized, :client_cert, :client_key,
|
|
19
|
+
keyword_init: true) do
|
|
20
|
+
def initialize(**kwargs)
|
|
21
|
+
super({ reject_unauthorized: true }.merge(kwargs))
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
Config = Struct.new(:address, :nodes, :database, :realm, :user, :password, :key, :key_version, :tls,
|
|
26
|
+
:insecure_no_tls, :read_consistency, :max_staleness_ms, :timeout, keyword_init: true) do
|
|
27
|
+
def initialize(**kwargs)
|
|
28
|
+
defaults = {
|
|
29
|
+
address: "", nodes: [], database: "", realm: "", user: "", password: "",
|
|
30
|
+
key: nil, key_version: 1, tls: nil, insecure_no_tls: false,
|
|
31
|
+
read_consistency: Protocol::READ_STRONG, max_staleness_ms: 0, timeout: 60.0
|
|
32
|
+
}
|
|
33
|
+
super(defaults.merge(kwargs))
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
Result = Struct.new(:columns, :rows, :affected)
|
|
38
|
+
|
|
39
|
+
# A single connection to one NextSQL node. Not safe for concurrent use
|
|
40
|
+
# from multiple threads/fibers — open one Connection per worker, or use
|
|
41
|
+
# +Cluster+ which pools one connection per node.
|
|
42
|
+
class Connection
|
|
43
|
+
LOOPBACK_RE = /\A127\.\d{1,3}\.\d{1,3}\.\d{1,3}\z/.freeze
|
|
44
|
+
CONNECT_TIMEOUT = 10.0
|
|
45
|
+
|
|
46
|
+
class << self
|
|
47
|
+
def connect(cfg)
|
|
48
|
+
new(cfg)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def split_host_port(addr, allow_bare: false)
|
|
52
|
+
if addr.start_with?("[")
|
|
53
|
+
e = addr.index("]")
|
|
54
|
+
raise Error.new("invalid_argument", "invalid address") unless e
|
|
55
|
+
|
|
56
|
+
host = addr[1...e]
|
|
57
|
+
rest = addr[(e + 1)..]
|
|
58
|
+
return [host, rest[1..].to_i] if rest.start_with?(":")
|
|
59
|
+
return [host, 0] if allow_bare
|
|
60
|
+
|
|
61
|
+
raise Error.new("invalid_argument", "address requires a port")
|
|
62
|
+
end
|
|
63
|
+
i = addr.rindex(":")
|
|
64
|
+
if i.nil?
|
|
65
|
+
return [addr, 0] if allow_bare
|
|
66
|
+
|
|
67
|
+
raise Error.new("invalid_argument", "address requires a port")
|
|
68
|
+
end
|
|
69
|
+
[addr[0...i], addr[(i + 1)..].to_i]
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def loopback?(addr)
|
|
73
|
+
host, = split_host_port(addr, allow_bare: true)
|
|
74
|
+
host = host.strip.downcase
|
|
75
|
+
return true if host == "localhost"
|
|
76
|
+
return true if %w[::1 0:0:0:0:0:0:0:1].include?(host)
|
|
77
|
+
|
|
78
|
+
LOOPBACK_RE.match?(host)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def validate_config!(cfg)
|
|
82
|
+
raise Error.new("invalid_argument", "address is required") if cfg.address.to_s.empty?
|
|
83
|
+
|
|
84
|
+
addr = cfg.address.downcase
|
|
85
|
+
if addr.include?("://") || addr.include?("key=") || addr.include?("password=")
|
|
86
|
+
raise Error.new("invalid_argument", "keys and credentials must not be passed in a URL")
|
|
87
|
+
end
|
|
88
|
+
if cfg.tls.nil? && !cfg.insecure_no_tls
|
|
89
|
+
raise Error.new("invalid_argument", "TLS is required for remote connections")
|
|
90
|
+
end
|
|
91
|
+
if cfg.insecure_no_tls && !loopback?(cfg.address)
|
|
92
|
+
raise Error.new("invalid_argument", "plaintext is only allowed on loopback")
|
|
93
|
+
end
|
|
94
|
+
raise Error.new("invalid_argument", "user is required") if cfg.user.to_s.empty?
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
LEADING_WS_RE = /\A[ \t\r\n(]+/.freeze
|
|
98
|
+
|
|
99
|
+
def strip_leading(s) = s.sub(LEADING_WS_RE, "")
|
|
100
|
+
|
|
101
|
+
def txn_control(sql)
|
|
102
|
+
up = strip_leading(sql).upcase
|
|
103
|
+
begin_ = up.start_with?("BEGIN") || up.start_with?("START TRANSACTION")
|
|
104
|
+
end_ = up.start_with?("COMMIT") || up.start_with?("ROLLBACK")
|
|
105
|
+
[begin_, end_]
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Conservative check: a false negative only costs a leader round trip,
|
|
109
|
+
# and a false positive self-corrects on the leader. EXPLAIN is
|
|
110
|
+
# excluded because EXPLAIN ANALYZE executes its statement.
|
|
111
|
+
def read_only_sql?(sql)
|
|
112
|
+
s = strip_leading(sql)
|
|
113
|
+
while s.start_with?("--")
|
|
114
|
+
i = s.index("\n")
|
|
115
|
+
return false unless i
|
|
116
|
+
|
|
117
|
+
s = strip_leading(s[(i + 1)..])
|
|
118
|
+
end
|
|
119
|
+
up = s.upcase
|
|
120
|
+
return true if up.start_with?("SELECT") || up.start_with?("SHOW")
|
|
121
|
+
return %w[INSERT UPDATE DELETE UPSERT].none? { |kw| up.include?(kw) } if up.start_with?("WITH")
|
|
122
|
+
|
|
123
|
+
false
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def dial(cfg)
|
|
127
|
+
host, port = split_host_port(cfg.address)
|
|
128
|
+
raw = begin
|
|
129
|
+
Socket.tcp(host, port, connect_timeout: CONNECT_TIMEOUT)
|
|
130
|
+
rescue SocketError, SystemCallError, IOError => e
|
|
131
|
+
raise Error.new("io", e.message)
|
|
132
|
+
end
|
|
133
|
+
raw.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1)
|
|
134
|
+
return raw if cfg.tls.nil?
|
|
135
|
+
|
|
136
|
+
tls = cfg.tls
|
|
137
|
+
ctx = OpenSSL::SSL::SSLContext.new
|
|
138
|
+
ctx.min_version = OpenSSL::SSL::TLS1_3_VERSION
|
|
139
|
+
if tls.reject_unauthorized == false
|
|
140
|
+
ctx.verify_mode = OpenSSL::SSL::VERIFY_NONE
|
|
141
|
+
else
|
|
142
|
+
ctx.verify_mode = OpenSSL::SSL::VERIFY_PEER
|
|
143
|
+
if tls.ca
|
|
144
|
+
ctx.cert_store = OpenSSL::X509::Store.new
|
|
145
|
+
ctx.cert_store.add_cert(OpenSSL::X509::Certificate.new(tls.ca))
|
|
146
|
+
elsif tls.cafile
|
|
147
|
+
ctx.ca_file = tls.cafile
|
|
148
|
+
else
|
|
149
|
+
ctx.cert_store = OpenSSL::X509::Store.new
|
|
150
|
+
ctx.cert_store.set_default_paths
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
if tls.client_cert
|
|
154
|
+
ctx.cert = OpenSSL::X509::Certificate.new(File.read(tls.client_cert))
|
|
155
|
+
ctx.key = OpenSSL::PKey.read(File.read(tls.client_key))
|
|
156
|
+
end
|
|
157
|
+
ssl = OpenSSL::SSL::SSLSocket.new(raw, ctx)
|
|
158
|
+
ssl.hostname = tls.server_name || host
|
|
159
|
+
begin
|
|
160
|
+
ssl.connect
|
|
161
|
+
rescue OpenSSL::SSL::SSLError => e
|
|
162
|
+
raw.close
|
|
163
|
+
raise Error.new("protocol", "tls handshake: #{e.message}")
|
|
164
|
+
end
|
|
165
|
+
ssl
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def initialize(cfg)
|
|
170
|
+
self.class.validate_config!(cfg)
|
|
171
|
+
@cfg = cfg
|
|
172
|
+
@sock = self.class.dial(cfg)
|
|
173
|
+
@secret = "".b
|
|
174
|
+
@busy = false
|
|
175
|
+
begin
|
|
176
|
+
handshake
|
|
177
|
+
set_read_consistency(cfg.read_consistency, cfg.max_staleness_ms) if cfg.read_consistency != Protocol::READ_STRONG
|
|
178
|
+
rescue StandardError
|
|
179
|
+
@sock&.close
|
|
180
|
+
raise
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def set_read_consistency(mode, max_staleness_ms = 0)
|
|
185
|
+
raise Error.new("conflict", "connection is busy") if @busy
|
|
186
|
+
|
|
187
|
+
write_frame(Protocol::TYPE_SET_READ_CONSISTENCY, Protocol.encode_set_read_consistency(mode, max_staleness_ms))
|
|
188
|
+
read_ack
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def node_status
|
|
192
|
+
raise Error.new("conflict", "connection is busy") if @busy
|
|
193
|
+
|
|
194
|
+
write_frame(Protocol::TYPE_NODE_STATUS, "")
|
|
195
|
+
typ, payload = read_frame
|
|
196
|
+
raise unexpected(typ, payload) if typ != Protocol::TYPE_NODE_STATUS_RESP
|
|
197
|
+
|
|
198
|
+
st = Protocol.decode_node_status(payload)
|
|
199
|
+
expect_ready
|
|
200
|
+
st
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def exec(sql, params = [])
|
|
204
|
+
query(sql, params).collect
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def query(sql, params = [])
|
|
208
|
+
raise Error.new("unavailable", "connection closed") if @sock.nil?
|
|
209
|
+
raise Error.new("conflict", "connection is busy") if @busy
|
|
210
|
+
|
|
211
|
+
@busy = true
|
|
212
|
+
begin
|
|
213
|
+
write_frame(Protocol::TYPE_QUERY, Protocol.encode_query(sql, params))
|
|
214
|
+
read_rows
|
|
215
|
+
rescue StandardError
|
|
216
|
+
@busy = false
|
|
217
|
+
raise
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# Executes a retryable mutation under a durable idempotency key: a
|
|
222
|
+
# retried call with the same key replays the original result instead of
|
|
223
|
+
# re-executing. See docs/sql.md / docs/protocol.md.
|
|
224
|
+
def exec_idempotent(key, sql, params = [])
|
|
225
|
+
query_idempotent(key, sql, params).collect
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def query_idempotent(key, sql, params = [])
|
|
229
|
+
raise Error.new("conflict", "connection is busy") if @busy
|
|
230
|
+
|
|
231
|
+
@busy = true
|
|
232
|
+
begin
|
|
233
|
+
write_frame(Protocol::TYPE_IDEMPOTENT_QUERY, Protocol.encode_idempotent_query(key, sql, params))
|
|
234
|
+
read_rows
|
|
235
|
+
rescue StandardError
|
|
236
|
+
@busy = false
|
|
237
|
+
raise
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def prepare(sql)
|
|
242
|
+
raise Error.new("conflict", "connection is busy") if @busy
|
|
243
|
+
|
|
244
|
+
write_frame(Protocol::TYPE_PREPARE, Protocol.u32bytes(sql.b, Protocol::MAX_SQL))
|
|
245
|
+
typ, payload = read_frame
|
|
246
|
+
raise unexpected(typ, payload) if typ != Protocol::TYPE_PREPARE_OK
|
|
247
|
+
raise Error.new("protocol", "bad prepare-ok length") unless payload.bytesize == 4
|
|
248
|
+
|
|
249
|
+
stmt_id = Protocol.u32(payload, 0)
|
|
250
|
+
expect_ready
|
|
251
|
+
Statement.new(self, stmt_id)
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def execute_prepared(stmt_id, params)
|
|
255
|
+
raise Error.new("conflict", "connection is busy") if @busy
|
|
256
|
+
|
|
257
|
+
@busy = true
|
|
258
|
+
begin
|
|
259
|
+
write_frame(Protocol::TYPE_EXECUTE, Protocol.encode_execute(stmt_id, params))
|
|
260
|
+
read_rows
|
|
261
|
+
rescue StandardError
|
|
262
|
+
@busy = false
|
|
263
|
+
raise
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def close_statement(stmt_id)
|
|
268
|
+
raise Error.new("conflict", "connection is busy") if @busy
|
|
269
|
+
|
|
270
|
+
write_frame(Protocol::TYPE_CLOSE_STMT, Protocol.u32le(stmt_id))
|
|
271
|
+
typ, payload = read_frame
|
|
272
|
+
raise unexpected(typ, payload) if typ != Protocol::TYPE_CLOSE_OK
|
|
273
|
+
|
|
274
|
+
expect_ready
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# Cancels the statement currently running on this connection, from a
|
|
278
|
+
# second, independent connection carrying this connection's secret.
|
|
279
|
+
# Safe to call from another thread while +query+/+exec+ blocks.
|
|
280
|
+
def cancel
|
|
281
|
+
raise Error.new("unavailable", "not connected") if @secret.empty?
|
|
282
|
+
|
|
283
|
+
side = self.class.dial(@cfg)
|
|
284
|
+
begin
|
|
285
|
+
tmp = self.class.allocate
|
|
286
|
+
tmp.instance_variable_set(:@sock, side)
|
|
287
|
+
tmp.instance_variable_set(:@busy, false)
|
|
288
|
+
tmp.write_frame(Protocol::TYPE_HELLO,
|
|
289
|
+
Protocol.encode_hello(Protocol::VERSION, Protocol::FLAG_CANCEL, @secret, "", ""))
|
|
290
|
+
typ, payload = tmp.read_frame
|
|
291
|
+
raise unexpected(typ, payload) if typ != Protocol::TYPE_READY
|
|
292
|
+
ensure
|
|
293
|
+
side.close
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def close
|
|
298
|
+
return if @sock.nil?
|
|
299
|
+
|
|
300
|
+
begin
|
|
301
|
+
write_frame(Protocol::TYPE_TERMINATE, "")
|
|
302
|
+
rescue Error
|
|
303
|
+
nil
|
|
304
|
+
end
|
|
305
|
+
@sock.close
|
|
306
|
+
@sock = nil
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def busy? = @busy
|
|
310
|
+
def release_busy! = (@busy = false)
|
|
311
|
+
|
|
312
|
+
# --- wire plumbing shared with Rows/Statement (internal API: stable
|
|
313
|
+
# within this driver, not part of the public Connection surface) ---
|
|
314
|
+
|
|
315
|
+
# Decodes an out-of-band Error frame (or reports a genuine protocol
|
|
316
|
+
# violation) for a call site checking "did I get what I expected?".
|
|
317
|
+
# writeErrReady on the server always sends Error then Ready — every
|
|
318
|
+
# call site funnels through here specifically so that trailing Ready
|
|
319
|
+
# is always drained in one place, rather than each of query/prepare/
|
|
320
|
+
# close_statement/etc. having to remember to do it individually (a
|
|
321
|
+
# per-call-site version of this is exactly the shape of bug this
|
|
322
|
+
# centralizes away).
|
|
323
|
+
def unexpected(typ, payload)
|
|
324
|
+
if typ == Protocol::TYPE_ERROR
|
|
325
|
+
err = Protocol.decode_error(payload)
|
|
326
|
+
begin
|
|
327
|
+
expect_ready
|
|
328
|
+
rescue Error
|
|
329
|
+
# Best-effort: surface the original application error even if
|
|
330
|
+
# draining the trailing Ready itself fails (e.g. the connection
|
|
331
|
+
# is now genuinely broken).
|
|
332
|
+
end
|
|
333
|
+
return err
|
|
334
|
+
end
|
|
335
|
+
Error.new("protocol", "unexpected message type")
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
def expect_ready
|
|
339
|
+
typ, payload = read_frame
|
|
340
|
+
raise unexpected(typ, payload) if typ != Protocol::TYPE_READY
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
def read_frame
|
|
344
|
+
hdr = read_exact(12)
|
|
345
|
+
raise Error.new("protocol", "bad magic") unless hdr.byteslice(0, 4) == "NSQL"
|
|
346
|
+
raise Error.new("protocol", "unsupported protocol version") unless Protocol.u16(hdr, 4) == Protocol::VERSION
|
|
347
|
+
|
|
348
|
+
typ = hdr.getbyte(6)
|
|
349
|
+
raise Error.new("protocol", "invalid message type") if typ.zero?
|
|
350
|
+
|
|
351
|
+
n = Protocol.u32(hdr, 8)
|
|
352
|
+
raise Error.new("protocol", "packet exceeds limit") if n > Protocol::MAX_PACKET
|
|
353
|
+
|
|
354
|
+
payload = n.zero? ? "".b : read_exact(n)
|
|
355
|
+
[typ, payload]
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
def write_frame(typ, payload)
|
|
359
|
+
raise Error.new("protocol", "payload exceeds packet limit") if payload.bytesize > Protocol::MAX_PACKET
|
|
360
|
+
|
|
361
|
+
hdr = +"NSQL".b
|
|
362
|
+
hdr << Protocol.u16le(Protocol::VERSION)
|
|
363
|
+
hdr << typ.chr << "\x00"
|
|
364
|
+
hdr << Protocol.u32le(payload.bytesize)
|
|
365
|
+
write_all(hdr + payload)
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
private
|
|
369
|
+
|
|
370
|
+
def handshake
|
|
371
|
+
cfg = @cfg
|
|
372
|
+
write_frame(Protocol::TYPE_HELLO, Protocol.encode_hello(Protocol::VERSION, 0, "\x00" * 8, cfg.database, cfg.user, cfg.realm))
|
|
373
|
+
typ, payload = read_frame
|
|
374
|
+
raise unexpected(typ, payload) if typ != Protocol::TYPE_HELLO_OK
|
|
375
|
+
|
|
376
|
+
_version, auth_method, secret = Protocol.decode_hello_ok(payload)
|
|
377
|
+
@secret = secret
|
|
378
|
+
write_frame(Protocol::TYPE_AUTH, Protocol.u16str(cfg.password))
|
|
379
|
+
typ, payload = read_frame
|
|
380
|
+
raise unexpected(typ, payload) if typ != Protocol::TYPE_AUTH_OK
|
|
381
|
+
|
|
382
|
+
if auth_method == Protocol::AUTH_PASSWORD_KEY
|
|
383
|
+
unless cfg.key && cfg.key.bytesize == 32
|
|
384
|
+
raise Error.new("unauthorized", "server requires a client-held key")
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
mat = Protocol.u32le(cfg.key_version) + cfg.key
|
|
388
|
+
write_frame(Protocol::TYPE_UNLOCK, mat)
|
|
389
|
+
typ, payload = read_frame
|
|
390
|
+
raise unexpected(typ, payload) if typ != Protocol::TYPE_UNLOCK_OK
|
|
391
|
+
end
|
|
392
|
+
typ, payload = read_frame
|
|
393
|
+
raise unexpected(typ, payload) if typ != Protocol::TYPE_READY
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
def read_rows
|
|
397
|
+
typ, payload = read_frame
|
|
398
|
+
if typ == Protocol::TYPE_ROW_DESC
|
|
399
|
+
return Rows.new(self, Protocol.decode_row_desc(payload))
|
|
400
|
+
end
|
|
401
|
+
if typ == Protocol::TYPE_COMMAND_COMPLETE
|
|
402
|
+
rows = Rows.new(self, [])
|
|
403
|
+
rows.affected = Protocol.decode_command_complete(payload)
|
|
404
|
+
expect_ready
|
|
405
|
+
@busy = false
|
|
406
|
+
rows.mark_closed!
|
|
407
|
+
return rows
|
|
408
|
+
end
|
|
409
|
+
err = unexpected(typ, payload)
|
|
410
|
+
@busy = false
|
|
411
|
+
raise err
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
def read_ack
|
|
415
|
+
typ, payload = read_frame
|
|
416
|
+
return if typ == Protocol::TYPE_READY
|
|
417
|
+
|
|
418
|
+
raise unexpected(typ, payload)
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
def read_exact(n)
|
|
422
|
+
return "".b if n.zero?
|
|
423
|
+
|
|
424
|
+
begin
|
|
425
|
+
@sock.read(n) || (raise Error.new("unavailable", "connection closed"))
|
|
426
|
+
rescue IOError, SystemCallError, OpenSSL::SSL::SSLError => e
|
|
427
|
+
raise Error.new("io", e.message)
|
|
428
|
+
end.tap do |got|
|
|
429
|
+
raise Error.new("unavailable", "connection closed") if got.bytesize != n
|
|
430
|
+
end
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
def write_all(data)
|
|
434
|
+
@sock.write(data)
|
|
435
|
+
rescue IOError, SystemCallError, OpenSSL::SSL::SSLError => e
|
|
436
|
+
raise Error.new("io", e.message)
|
|
437
|
+
end
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
# A streaming query result. Iterate directly, or call +collect+ for a
|
|
441
|
+
# materialized +Result+.
|
|
442
|
+
class Rows
|
|
443
|
+
include Enumerable
|
|
444
|
+
|
|
445
|
+
attr_reader :columns
|
|
446
|
+
attr_accessor :affected
|
|
447
|
+
|
|
448
|
+
def initialize(conn, columns)
|
|
449
|
+
@conn = conn
|
|
450
|
+
@columns = columns.map(&:name)
|
|
451
|
+
@affected = 0
|
|
452
|
+
@batch = []
|
|
453
|
+
@i = -1
|
|
454
|
+
@done = columns.empty?
|
|
455
|
+
@closed = false
|
|
456
|
+
@err = nil
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
def next?
|
|
460
|
+
return false if @closed || @err
|
|
461
|
+
|
|
462
|
+
if @i + 1 < @batch.size
|
|
463
|
+
@i += 1
|
|
464
|
+
return true
|
|
465
|
+
end
|
|
466
|
+
return false if @done
|
|
467
|
+
|
|
468
|
+
begin
|
|
469
|
+
fill
|
|
470
|
+
rescue Error => e
|
|
471
|
+
@err = e
|
|
472
|
+
return false
|
|
473
|
+
end
|
|
474
|
+
if @i + 1 < @batch.size
|
|
475
|
+
@i += 1
|
|
476
|
+
return true
|
|
477
|
+
end
|
|
478
|
+
false
|
|
479
|
+
end
|
|
480
|
+
|
|
481
|
+
def values
|
|
482
|
+
return nil if @i.negative? || @i >= @batch.size
|
|
483
|
+
|
|
484
|
+
@batch[@i]
|
|
485
|
+
end
|
|
486
|
+
|
|
487
|
+
def err = @err
|
|
488
|
+
|
|
489
|
+
def each
|
|
490
|
+
return enum_for(:each) unless block_given?
|
|
491
|
+
|
|
492
|
+
begin
|
|
493
|
+
while next?
|
|
494
|
+
row = values
|
|
495
|
+
yield row if row
|
|
496
|
+
end
|
|
497
|
+
raise @err if @err
|
|
498
|
+
ensure
|
|
499
|
+
close unless @closed
|
|
500
|
+
end
|
|
501
|
+
end
|
|
502
|
+
|
|
503
|
+
def close
|
|
504
|
+
nil while next?
|
|
505
|
+
finish unless @closed
|
|
506
|
+
if @err
|
|
507
|
+
e = @err
|
|
508
|
+
@err = nil
|
|
509
|
+
raise e
|
|
510
|
+
end
|
|
511
|
+
end
|
|
512
|
+
|
|
513
|
+
def collect
|
|
514
|
+
out = []
|
|
515
|
+
begin
|
|
516
|
+
while next?
|
|
517
|
+
row = values
|
|
518
|
+
out << row if row
|
|
519
|
+
end
|
|
520
|
+
raise @err if @err
|
|
521
|
+
ensure
|
|
522
|
+
close unless @closed
|
|
523
|
+
end
|
|
524
|
+
Result.new(@columns, out, @affected)
|
|
525
|
+
end
|
|
526
|
+
|
|
527
|
+
def mark_closed!
|
|
528
|
+
@closed = true
|
|
529
|
+
@done = true
|
|
530
|
+
end
|
|
531
|
+
|
|
532
|
+
# @api private
|
|
533
|
+
def fill
|
|
534
|
+
@conn.write_frame(Protocol::TYPE_FLOW_ACK, "") if !@done && !@batch.empty?
|
|
535
|
+
typ, payload = @conn.read_frame
|
|
536
|
+
if typ == Protocol::TYPE_DATA_BATCH
|
|
537
|
+
@batch = Protocol.decode_data_batch(payload)
|
|
538
|
+
@i = -1
|
|
539
|
+
return
|
|
540
|
+
end
|
|
541
|
+
if typ == Protocol::TYPE_COMMAND_COMPLETE
|
|
542
|
+
@affected = Protocol.decode_command_complete(payload)
|
|
543
|
+
@done = true
|
|
544
|
+
@batch = []
|
|
545
|
+
@i = -1
|
|
546
|
+
@conn.expect_ready
|
|
547
|
+
finish
|
|
548
|
+
return
|
|
549
|
+
end
|
|
550
|
+
raise @conn.unexpected(typ, payload)
|
|
551
|
+
end
|
|
552
|
+
|
|
553
|
+
private
|
|
554
|
+
|
|
555
|
+
def finish
|
|
556
|
+
@conn.release_busy! unless @closed
|
|
557
|
+
@closed = true
|
|
558
|
+
end
|
|
559
|
+
end
|
|
560
|
+
|
|
561
|
+
# A prepared statement. Close it when done, or wrap it in +with_statement+
|
|
562
|
+
# for automatic cleanup.
|
|
563
|
+
class Statement
|
|
564
|
+
def initialize(conn, stmt_id)
|
|
565
|
+
@conn = conn
|
|
566
|
+
@id = stmt_id
|
|
567
|
+
end
|
|
568
|
+
|
|
569
|
+
def query(params = [])
|
|
570
|
+
@conn.execute_prepared(@id, params)
|
|
571
|
+
end
|
|
572
|
+
|
|
573
|
+
def exec(params = [])
|
|
574
|
+
query(params).collect
|
|
575
|
+
end
|
|
576
|
+
|
|
577
|
+
def close
|
|
578
|
+
return if @id.zero?
|
|
579
|
+
|
|
580
|
+
@conn.close_statement(@id)
|
|
581
|
+
@id = 0
|
|
582
|
+
end
|
|
583
|
+
end
|
|
584
|
+
|
|
585
|
+
# Prepares +sql+ on +conn+, yields the Statement, and closes it
|
|
586
|
+
# afterward even if the block raises.
|
|
587
|
+
def self.with_statement(conn, sql)
|
|
588
|
+
stmt = conn.prepare(sql)
|
|
589
|
+
begin
|
|
590
|
+
yield stmt
|
|
591
|
+
ensure
|
|
592
|
+
stmt.close
|
|
593
|
+
end
|
|
594
|
+
end
|
|
595
|
+
end
|