nusadb 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.
@@ -0,0 +1,537 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'socket'
4
+ require 'bigdecimal'
5
+ require 'date'
6
+ require 'time'
7
+ require 'json'
8
+
9
+ module NusaDB
10
+ # A driver or server error; +sql_state+ is the 5-character SQLSTATE (docs/wire-protocol.md §14).
11
+ class NusaError < StandardError
12
+ attr_reader :sql_state
13
+
14
+ def initialize(message, sql_state = 'HY000')
15
+ super(message)
16
+ @sql_state = sql_state
17
+ end
18
+ end
19
+
20
+ # The result of a statement: column names, rows (each an array of String/nil), and the command tag.
21
+ class Result
22
+ include Enumerable
23
+
24
+ attr_reader :columns, :column_types, :rows, :command
25
+
26
+ def initialize(columns, rows, command, column_types = nil)
27
+ @columns = columns
28
+ @rows = rows
29
+ @command = command
30
+ # Per-column type name (protocol 1.1, R42-B.03), parallel to +columns+; each entry is a
31
+ # canonical name such as 'INT'/'TEXT' or nil if the server did not report it.
32
+ @column_types = column_types || Array.new(columns.size)
33
+ end
34
+
35
+ # Iterate rows as hashes keyed by column name.
36
+ def each
37
+ return enum_for(:each) unless block_given?
38
+
39
+ @rows.each do |row|
40
+ yield @columns.each_with_index.to_h { |name, i| [name, row[i]] }
41
+ end
42
+ end
43
+
44
+ def to_a
45
+ each.to_a
46
+ end
47
+
48
+ # The affected-row count parsed from the trailing number of the command tag.
49
+ def affected
50
+ return 0 if @command.nil?
51
+
52
+ last = @command.split(' ').last
53
+ last =~ /\A\d+\z/ ? last.to_i : 0
54
+ end
55
+ end
56
+
57
+ # An asynchronous LISTEN/NOTIFY message delivered by the server: the notifying backend's +pid+, the
58
+ # +channel+, and its (possibly empty) +payload+.
59
+ Notification = Struct.new(:pid, :channel, :payload)
60
+
61
+ # One authenticated connection to a NusaDB server.
62
+ class Connection
63
+ attr_reader :backend_key
64
+
65
+ def initialize(host: '127.0.0.1', port: 5678, user: 'nusa-root', database: 'nusadb', password: 'nusa-root')
66
+ @user = user
67
+ @counter = 0
68
+ @backend_key = nil
69
+ # Async LISTEN/NOTIFY messages received while reading other responses; drained by #notifications.
70
+ @notifications = []
71
+ begin
72
+ @socket = TCPSocket.new(host, port)
73
+ # Disable Nagle's algorithm: this is a request/response protocol, so
74
+ # coalescing small frames only adds delayed-ACK latency per round-trip.
75
+ @socket.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1)
76
+ rescue SystemCallError => e
77
+ raise NusaError.new("nusadb: connect failed: #{e.message}", '08001')
78
+ end
79
+ handshake(user, database, password)
80
+ end
81
+
82
+ # Run one SQL string, optionally with positional parameters ($1, $2, …).
83
+ def query(sql, params = [])
84
+ raw = if params && !params.empty?
85
+ run_extended(sql, params)
86
+ else
87
+ run_simple(sql)
88
+ end
89
+ shape(raw)
90
+ end
91
+
92
+ # Prepare a statement for repeated execution; returns an object with #execute(params).
93
+ def prepare(sql)
94
+ name = fresh_name('stmt')
95
+ send_frame(Protocol.parse(name, sql))
96
+ send_frame(Protocol.sync)
97
+ collect # surfaces a parse error
98
+ Statement.new(self, name)
99
+ end
100
+
101
+ # Run a statement returning the affected-row count.
102
+ def execute(sql, params = [])
103
+ query(sql, params).affected
104
+ end
105
+
106
+ # Run one statement once per parameter set, reusing a single prepared statement — the bulk
107
+ # insert/update path. Returns an Array of per-set affected-row counts. One parse, then one
108
+ # execute per set (the wire protocol has no batch pipeline, so this is N round-trips, not one);
109
+ # the first failing set raises.
110
+ def execute_many(sql, param_sets)
111
+ stmt = prepare(sql)
112
+ (param_sets || []).map { |params| stmt.execute(params).affected }
113
+ end
114
+
115
+ # Bulk-load via COPY ... FROM STDIN (§12.1). +source+ is an IO (responds to #read) or a String
116
+ # already in the server's text format (tab-delimited, \N for NULL). Returns the loaded row count.
117
+ # A refused COPY (bad SQL, an RLS-protected table) raises; the connection stays usable.
118
+ def copy_in(sql, source)
119
+ send_frame(Protocol.query(sql))
120
+ await_copy_start(Protocol::B_COPY_IN)
121
+ if source.respond_to?(:read)
122
+ loop do
123
+ # A read error on the source aborts the load with CopyFail (mirrors the other drivers),
124
+ # then drains to ReadyForQuery so the connection stays usable.
125
+ begin
126
+ chunk = source.read(65_536)
127
+ rescue StandardError => e
128
+ send_frame(Protocol.copy_fail('nusadb: client read error during COPY'))
129
+ drain_to_ready
130
+ raise NusaError.new("nusadb: COPY source read failed: #{e.message}", '57014')
131
+ end
132
+ break if chunk.nil?
133
+
134
+ send_frame(Protocol.copy_data(chunk.b)) unless chunk.empty?
135
+ end
136
+ else
137
+ bytes = source.to_s.dup.force_encoding(Encoding::BINARY)
138
+ (0...bytes.bytesize).step(65_536) { |off| send_frame(Protocol.copy_data(bytes.byteslice(off, 65_536))) }
139
+ end
140
+ send_frame(Protocol.copy_done)
141
+ finish_copy
142
+ end
143
+
144
+ # Bulk-export via COPY ... TO STDOUT (§12.2). Writes each data chunk to +sink+ (responds to
145
+ # #write) in the server's text format; returns the exported row count.
146
+ def copy_out(sql, sink)
147
+ send_frame(Protocol.query(sql))
148
+ await_copy_start(Protocol::B_COPY_OUT)
149
+ loop do
150
+ type, reader = read_message
151
+ case type
152
+ when Protocol::B_COPY_DATA then sink.write(reader.rest)
153
+ when Protocol::B_COPY_DONE then break
154
+ when Protocol::B_ERROR
155
+ err = server_error(reader)
156
+ drain_to_ready
157
+ raise err
158
+ end
159
+ end
160
+ finish_copy
161
+ end
162
+
163
+ def close
164
+ send_frame(Protocol.terminate)
165
+ rescue IOError, SystemCallError
166
+ # best effort
167
+ ensure
168
+ @socket.close unless @socket.closed?
169
+ end
170
+
171
+ # Subscribe to asynchronous notifications on +channel+ (+LISTEN channel+). Collect them with
172
+ # #notifications or #poll.
173
+ def listen(channel)
174
+ run_simple("LISTEN #{quote_identifier(channel)}")
175
+ nil
176
+ end
177
+
178
+ # Stop listening on +channel+ (+UNLISTEN channel+); +nil+ unlistens all (+UNLISTEN *+).
179
+ def unlisten(channel = nil)
180
+ target = channel.nil? ? '*' : quote_identifier(channel)
181
+ run_simple("UNLISTEN #{target}")
182
+ nil
183
+ end
184
+
185
+ # Send a notification on +channel+ with an optional +payload+ (+NOTIFY channel[, 'payload']+).
186
+ def notify(channel, payload = nil)
187
+ sql = "NOTIFY #{quote_identifier(channel)}"
188
+ sql += ", #{quote_literal(payload)}" unless payload.nil?
189
+ run_simple(sql)
190
+ nil
191
+ end
192
+
193
+ # Return and clear the notifications already received (buffered while reading other responses).
194
+ # Does not touch the socket -- use #poll to wait for new ones.
195
+ def notifications
196
+ pending = @notifications
197
+ @notifications = []
198
+ pending
199
+ end
200
+
201
+ # Wait up to +timeout+ seconds for the next notification (a buffered one is returned immediately;
202
+ # +nil+ on timeout). +timeout+ of +nil+ blocks until one arrives. Only meaningful after #listen.
203
+ def poll(timeout = 0)
204
+ return @notifications.shift unless @notifications.empty?
205
+
206
+ return nil if IO.select([@socket], nil, nil, timeout).nil?
207
+
208
+ type, reader = read_message
209
+ case type
210
+ when Protocol::B_NOTIFICATION then decode_notification(reader)
211
+ when Protocol::B_ERROR then raise server_error(reader)
212
+ else
213
+ raise NusaError.new('nusadb: unexpected message while polling for notifications', '08006')
214
+ end
215
+ end
216
+
217
+ # @api private
218
+ def run_prepared(name, params)
219
+ portal = fresh_name('portal')
220
+ send_frame(Protocol.bind(portal, name, encode_params(params)))
221
+ send_frame(Protocol.describe_portal(portal))
222
+ send_frame(Protocol.execute(portal, 0))
223
+ send_frame(Protocol.sync)
224
+ shape(collect)
225
+ end
226
+
227
+ private
228
+
229
+ def decode_notification(reader)
230
+ Notification.new(reader.u32, reader.str, reader.str)
231
+ end
232
+
233
+ # Quote an identifier (a LISTEN/NOTIFY channel) so an arbitrary name is emitted safely; the
234
+ # server folds a quoted identifier to its literal value.
235
+ def quote_identifier(name)
236
+ %("#{name.to_s.gsub('"', '""')}")
237
+ end
238
+
239
+ # Quote a string as a SQL literal ('...', doubling '), for a NOTIFY payload.
240
+ def quote_literal(text)
241
+ "'#{text.to_s.gsub("'", "''")}'"
242
+ end
243
+
244
+ def send_frame(frame)
245
+ @socket.write(frame)
246
+ end
247
+
248
+ def read_exact(n)
249
+ data = @socket.read(n)
250
+ raise NusaError.new('nusadb: server closed the connection', '08006') if data.nil? || data.bytesize < n
251
+
252
+ data
253
+ end
254
+
255
+ def read_message
256
+ header = read_exact(Protocol::HEADER_LEN)
257
+ type = header.getbyte(0)
258
+ total = header.byteslice(1, 4).unpack1('N')
259
+ if total < Protocol::HEADER_LEN || total > Protocol::MAX_FRAME_LEN
260
+ raise NusaError.new("nusadb: invalid frame length #{total}", '08P01')
261
+ end
262
+
263
+ payload = total > Protocol::HEADER_LEN ? read_exact(total - Protocol::HEADER_LEN) : ''.b
264
+ [type, Reader.new(payload)]
265
+ end
266
+
267
+ def handshake(user, database, password)
268
+ send_frame(Protocol.startup(user, database))
269
+ loop do
270
+ type, reader = read_message
271
+ case type
272
+ when Protocol::B_READY then return
273
+ when Protocol::B_ERROR then raise server_error(reader)
274
+ when Protocol::B_AUTH
275
+ scram(reader, user, password) if reader.u32 == Protocol::AUTH_SASL
276
+ when Protocol::B_BACKEND_KEY
277
+ @backend_key = { pid: reader.u32, secret: reader.u32 }
278
+ end
279
+ end
280
+ end
281
+
282
+ def scram(offer, user, password)
283
+ count = offer.u16
284
+ supported = false
285
+ count.times { supported = true if offer.str == Protocol::SCRAM_MECHANISM }
286
+ raise NusaError.new('nusadb: server offered no supported SASL mechanism', '28000') unless supported
287
+ raise NusaError.new('nusadb: server requires a password but none was given', '28000') if password.nil?
288
+
289
+ scram = Scram.new(user)
290
+ send_frame(Protocol.sasl_initial(Protocol::SCRAM_MECHANISM, scram.full_client_first.b))
291
+
292
+ type, reader = read_message
293
+ raise server_error(reader) if type == Protocol::B_ERROR
294
+ unless type == Protocol::B_AUTH && reader.u32 == Protocol::AUTH_SASL_CONTINUE
295
+ raise NusaError.new('nusadb: expected a SASL continue message', '08P01')
296
+ end
297
+ server_first = reader.rest.force_encoding(Encoding::UTF_8)
298
+
299
+ send_frame(Protocol.sasl_response(scram.client_final(password, server_first).b))
300
+
301
+ type, reader = read_message
302
+ raise server_error(reader) if type == Protocol::B_ERROR
303
+ unless type == Protocol::B_AUTH && reader.u32 == Protocol::AUTH_SASL_FINAL
304
+ raise NusaError.new('nusadb: expected a SASL final message', '08P01')
305
+ end
306
+ return if scram.verify_server_final(reader.rest.force_encoding(Encoding::UTF_8))
307
+
308
+ raise NusaError.new('nusadb: server signature did not verify', '28000')
309
+ end
310
+
311
+ def collect
312
+ columns = []
313
+ column_types = []
314
+ rows = []
315
+ tag = nil
316
+ error = nil
317
+ loop do
318
+ type, reader = read_message
319
+ case type
320
+ when Protocol::B_ROW_DESCRIPTION
321
+ columns = Array.new(reader.u16) { reader.str }
322
+ column_types = Array.new(columns.size)
323
+ when Protocol::B_ROW_DESCRIPTION_TYPED
324
+ # Protocol 1.1: each column is a name plus a 1-byte type tag (§9.2).
325
+ count = reader.u16
326
+ columns = []
327
+ column_types = []
328
+ count.times do
329
+ columns << reader.str
330
+ column_types << Protocol.type_name(reader.u8)
331
+ end
332
+ when Protocol::B_DATA_ROW
333
+ rows << reader.fields
334
+ when Protocol::B_COMMAND_COMPLETE
335
+ tag = reader.str
336
+ when Protocol::B_NOTIFICATION
337
+ # A pending LISTEN/NOTIFY message can lead the next query's response; buffer it.
338
+ @notifications << decode_notification(reader)
339
+ when Protocol::B_ERROR
340
+ error = server_error(reader)
341
+ when Protocol::B_READY
342
+ raise error if error
343
+
344
+ return { columns: columns, column_types: column_types, rows: rows, tag: tag }
345
+ end
346
+ end
347
+ end
348
+
349
+ def run_simple(sql)
350
+ send_frame(Protocol.query(sql))
351
+ collect
352
+ end
353
+
354
+ # Read until the expected CopyInResponse/CopyOutResponse; a refused COPY arrives as Error then
355
+ # ReadyForQuery and is raised, the connection left ready.
356
+ def await_copy_start(expected)
357
+ error = nil
358
+ loop do
359
+ type, reader = read_message
360
+ case type
361
+ when expected then return
362
+ when Protocol::B_ERROR then error = server_error(reader)
363
+ when Protocol::B_READY
364
+ raise(error || NusaError.new('nusadb: COPY did not start', '08P01'))
365
+ end
366
+ end
367
+ end
368
+
369
+ # Drain to ReadyForQuery, returning the row count from the "COPY <n>" command tag.
370
+ def finish_copy
371
+ tag = nil
372
+ error = nil
373
+ loop do
374
+ type, reader = read_message
375
+ case type
376
+ when Protocol::B_COMMAND_COMPLETE then tag = reader.str
377
+ when Protocol::B_ERROR then error = server_error(reader)
378
+ when Protocol::B_READY
379
+ raise error if error
380
+
381
+ return copy_count(tag)
382
+ end
383
+ end
384
+ end
385
+
386
+ # Best-effort resync: read and discard frames until ReadyForQuery.
387
+ def drain_to_ready
388
+ loop { break if read_message[0] == Protocol::B_READY }
389
+ end
390
+
391
+ def copy_count(tag)
392
+ return 0 if tag.nil?
393
+
394
+ last = tag.split(' ').last
395
+ last =~ /\A\d+\z/ ? last.to_i : 0
396
+ end
397
+
398
+ def run_extended(sql, params)
399
+ send_frame(Protocol.parse('', sql))
400
+ send_frame(Protocol.bind('', '', encode_params(params)))
401
+ send_frame(Protocol.describe_portal(''))
402
+ send_frame(Protocol.execute('', 0))
403
+ send_frame(Protocol.sync)
404
+ collect
405
+ end
406
+
407
+ def encode_params(params)
408
+ params.map do |value|
409
+ if value.nil?
410
+ nil
411
+ elsif value == true
412
+ 'true'.b
413
+ elsif value == false
414
+ 'false'.b
415
+ elsif value.is_a?(String) && value.encoding == Encoding::BINARY
416
+ # A binary-encoded String is BYTEA: send the \x<hex> text the server coerces into BYTEA.
417
+ "\\x#{value.unpack1('H*')}".b
418
+ else
419
+ value.to_s.dup.force_encoding(Encoding::BINARY)
420
+ end
421
+ end
422
+ end
423
+
424
+ def shape(raw)
425
+ types = raw[:column_types] || []
426
+ rows = raw[:rows].map do |fields|
427
+ fields.each_index.map { |i| decode_cell(fields[i], types[i]) }
428
+ end
429
+ Result.new(raw[:columns], rows, raw[:tag], raw[:column_types])
430
+ end
431
+
432
+ # Decode a text-format cell into the natural Ruby type for its protocol 1.1 type tag: BOOL ->
433
+ # boolean, INT -> Integer, FLOAT -> Float, NUMERIC -> BigDecimal, DATE -> Date, TIMESTAMP ->
434
+ # Time, JSON -> parsed value, ARRAY -> Array, BYTES -> binary String. TEXT/UUID/TIME/INTERVAL
435
+ # stay UTF-8 strings. A value that does not parse as its tag falls back to the raw text.
436
+ def decode_cell(cell, type)
437
+ return nil if cell.nil?
438
+ return decode_bytea(cell) if type == 'BYTES'
439
+
440
+ text = cell.force_encoding(Encoding::UTF_8)
441
+ case type
442
+ when 'BOOL' then %w[true t 1].include?(text)
443
+ when 'INT' then (Integer(text, 10) rescue text)
444
+ when 'FLOAT' then (Float(text) rescue text)
445
+ when 'NUMERIC' then (BigDecimal(text) rescue text)
446
+ when 'DATE' then (Date.parse(text) rescue text)
447
+ when 'TIMESTAMP', 'TIMESTAMPTZ' then (Time.parse(text) rescue text)
448
+ when 'JSON' then (JSON.parse(text) rescue text)
449
+ when 'ARRAY' then (parse_array(text) rescue text)
450
+ else text
451
+ end
452
+ end
453
+
454
+ def decode_bytea(cell)
455
+ text = cell.force_encoding(Encoding::UTF_8)
456
+ hex = text.start_with?('\x') ? text[2..] : text
457
+ [hex].pack('H*')
458
+ end
459
+
460
+ # Parse a SQL array literal (`{a,b,c}`, possibly nested, quoted, or with NULL) into a Ruby
461
+ # Array. Elements stay strings (the wire array tag carries no per-element type); a quoted element
462
+ # is unescaped and an unquoted NULL becomes nil.
463
+ def parse_array(text)
464
+ value, pos = parse_array_at(text, 0)
465
+ raise 'trailing array data' unless pos == text.length
466
+
467
+ value
468
+ end
469
+
470
+ def parse_array_at(text, pos)
471
+ raise 'not an array' unless text[pos] == '{'
472
+
473
+ pos += 1
474
+ items = []
475
+ return [items, pos + 1] if text[pos] == '}'
476
+
477
+ loop do
478
+ case text[pos]
479
+ when '{' then val, pos = parse_array_at(text, pos)
480
+ when '"' then val, pos = parse_quoted_at(text, pos)
481
+ else val, pos = parse_unquoted_at(text, pos)
482
+ end
483
+ items << val
484
+ case text[pos]
485
+ when ',' then pos += 1
486
+ when '}' then return [items, pos + 1]
487
+ else raise 'malformed array'
488
+ end
489
+ end
490
+ end
491
+
492
+ def parse_quoted_at(text, pos)
493
+ pos += 1 # opening quote
494
+ out = +''
495
+ while text[pos] != '"'
496
+ raise 'unterminated array element' if pos >= text.length
497
+
498
+ pos += 1 if text[pos] == '\\'
499
+ out << text[pos]
500
+ pos += 1
501
+ end
502
+ [out, pos + 1]
503
+ end
504
+
505
+ def parse_unquoted_at(text, pos)
506
+ start = pos
507
+ pos += 1 while pos < text.length && text[pos] != ',' && text[pos] != '}'
508
+ token = text[start...pos]
509
+ [token == 'NULL' ? nil : token, pos]
510
+ end
511
+
512
+ def fresh_name(prefix)
513
+ @counter += 1
514
+ "nusa_#{prefix}_#{@counter}"
515
+ end
516
+
517
+ def server_error(reader)
518
+ code = reader.str
519
+ message = reader.str
520
+ NusaError.new("nusadb: #{message}", code)
521
+ end
522
+ end
523
+
524
+ # A prepared statement handle.
525
+ class Statement
526
+ attr_reader :name
527
+
528
+ def initialize(connection, name)
529
+ @connection = connection
530
+ @name = name
531
+ end
532
+
533
+ def execute(params = [])
534
+ @connection.run_prepared(@name, params)
535
+ end
536
+ end
537
+ end