bzync-nextsql 0.0.1

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