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.
- checksums.yaml +7 -0
- data/README.md +144 -0
- data/lib/active_record/connection_adapters/nusadb_adapter.rb +355 -0
- data/lib/nusadb/connection.rb +537 -0
- data/lib/nusadb/protocol.rb +210 -0
- data/lib/nusadb/scram.rb +82 -0
- data/lib/nusadb.rb +23 -0
- metadata +52 -0
|
@@ -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
|