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 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