Class: Frostlake::Connection

Inherits:
Object
  • Object
show all
Defined in:
lib/frostlake.rb

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(dsn, open_timeout: nil, read_timeout: nil, verify_ssl: nil, ca_file: nil, session_idle_limit: nil) ⇒ Connection

Returns a new instance of Connection.

Raises:



133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
# File 'lib/frostlake.rb', line 133

def initialize(dsn, open_timeout: nil, read_timeout: nil, verify_ssl: nil, ca_file: nil,
               session_idle_limit: nil)
  uri = begin
    URI.parse(dsn)
  rescue URI::InvalidURIError
    raise UsageError, "invalid DSN: #{dsn}"
  end
  scheme = (uri.scheme || "").downcase
  unless %w[frostlake http https].include?(scheme)
    raise UsageError, "DSN must start with frostlake://, http:// or https://"
  end
  raise UsageError, "DSN is missing host[:port]" if uri.host.nil? || uri.host.empty?
  # The server authenticates nobody, so credentials in a DSN would be
  # quietly dropped — and quietly dropping a password is worse than saying so.
  unless uri.userinfo.nil?
    raise UsageError, "the server takes no credentials; remove user:password from the DSN"
  end

  query = uri.query.nil? ? {} : URI.decode_www_form(uri.query).to_h
  unknown = query.keys - DSN_PARAMETERS
  unless unknown.empty?
    raise UsageError, "unknown DSN parameter: #{unknown.sort.join(', ')} " \
                      "(expected #{DSN_PARAMETERS.join(', ')})"
  end
  @host = uri.host
  # URI supplies 80 and 443 for http and https; only the custom scheme needs
  # the engine's own default.
  @port = uri.port || DEFAULT_PORT
  unless (1..65_535).cover?(@port)
    raise UsageError, "DSN port must be between 1 and 65535, got #{@port}"
  end
  # Net::HTTP opens a fresh connection per request, which is deliberate:
  # against DatabaseHttpServer a reused connection costs ~48 ms a statement
  # (a delayed-ACK stall that TCP_NODELAY does not shift) versus ~0.8 ms for
  # a new one. Do not "optimise" this into a kept-alive session.
  @http = Net::HTTP.new(@host, @port)
  if scheme == "https"
    configure_tls(verify_ssl, ca_file, query)
  elsif !verify_ssl.nil? || !ca_file.nil? || query.key?("verify_ssl") || query.key?("ca_file")
    # However they were spelled — keyword or DSN — they would do nothing here.
    raise UsageError, "verify_ssl and ca_file apply to https DSNs only"
  end
  @http.open_timeout = self.class.timeout_for("open_timeout", open_timeout,
                                              query["open_timeout"], DEFAULT_OPEN_TIMEOUT)
  @http.read_timeout = self.class.timeout_for("read_timeout", read_timeout,
                                              query["read_timeout"], DEFAULT_READ_TIMEOUT)
  # One socket and one session id per connection: statements serialize so a
  # Connection can be shared between threads without interleaving them. A
  # Monitor rather than a Mutex because execute_all holds the lock across
  # the round trips it makes, each of which takes it again.
  @lock = Monitor.new
  @session_idle_limit = self.class.idle_limit_for(session_idle_limit,
                                                  query["session_idle_limit"])
  @last_used_at = nil
  # Whether the caller has selected anything themselves; if they have, the
  # DSN's defaults are no longer the whole truth about this session.
  @session_touched = false
  @session_id = nil
  @autocommit = true
  @closed = false
  @pending_use = []
  # A trailing slash is fine; a second segment means the caller meant
  # something the DSN cannot express, and "db/extra" is not an identifier.
  database = (uri.path || "").delete_prefix("/").delete_suffix("/")
  if database.include?("/")
    raise UsageError, "the DSN path names one database, got #{uri.path.inspect}"
  end
  schema = query["schema"]
  @pending_use << "USE DATABASE #{self.class.quote_ident(database)}" unless database.empty?
  @pending_use << "USE SCHEMA #{self.class.quote_ident(schema)}" if schema
  # Kept so they can be put back if the session is replaced under us.
  @session_defaults = @pending_use.dup.freeze
end

Class Method Details

.boolean_for(name, argument, from_dsn, fallback) ⇒ Object

An explicit argument wins over the DSN, which wins over the default.



515
516
517
518
519
520
521
522
523
524
525
526
# File 'lib/frostlake.rb', line 515

def boolean_for(name, argument, from_dsn, fallback)
  given = argument.nil? ? from_dsn : argument
  return fallback if given.nil?
  return given if given == true || given == false

  case given.to_s.downcase
  when "true", "1", "yes" then true
  when "false", "0", "no" then false
  else
    raise UsageError, "#{name} must be true or false, got #{given.inspect}"
  end
end

.convert(value, data_type, scale = 0) ⇒ Object



422
423
424
425
426
427
428
429
430
431
432
433
434
435
# File 'lib/frostlake.rb', line 422

def convert(value, data_type, scale = 0)
  return nil if value.nil?

  case (data_type || "").upcase
  when "DATE"
    value.is_a?(String) ? Date.parse(value) : value
  when "TIMESTAMP", "TIMESTAMP_NTZ", "TIMESTAMP_LTZ", "TIMESTAMP_TZ", "DATETIME"
    value.is_a?(String) ? Time.parse(value) : value
  when "BINARY", "VARBINARY"
    value.is_a?(String) ? decode_hex(value) : value
  else
    convert_number(value, (data_type || "").upcase, scale)
  end
end

.convert_number(value, data_type, scale) ⇒ Object

Fixed-point columns keep the wire's exact digits; FLOAT/DOUBLE/REAL are genuine binary floats and stay that way. Anything non-numeric — strings, booleans, semi-structured JSON text — passes straight through.



449
450
451
452
453
454
455
456
457
458
459
# File 'lib/frostlake.rb', line 449

def convert_number(value, data_type, scale)
  return value unless value.is_a?(Numeric)
  return value.to_f if APPROXIMATE_TYPES.include?(data_type)
  return value if value.is_a?(Integer)
  return value unless defined?(BigDecimal) && value.is_a?(BigDecimal)
  # Scale 0 is an integer column; hand back an Integer, but never truncate
  # a value that unexpectedly carries a fraction.
  return value.to_i if scale.to_i.zero? && value.frac.zero?

  value
end

.decode_hex(text) ⇒ Object

The engine renders binary as hex. Anything else is not ours to reinterpret: pack("H*") turns "ZZ" into a byte and pads odd-length input rather than admitting it was handed something else.



440
441
442
443
444
# File 'lib/frostlake.rb', line 440

def decode_hex(text)
  return text unless text.match?(/\A(?:[0-9a-fA-F]{2})*\z/)

  [text].pack("H*")
end

.dml_row_count(columns, cells) ⇒ Object

Total rows affected. "number of multi-joined rows updated" is a diagnostic sub-count of rows already counted as updated, so only the "number of rows ..." counters are summed.



478
479
480
481
482
483
484
485
486
487
# File 'lib/frostlake.rb', line 478

def dml_row_count(columns, cells)
  total = 0
  columns.each_with_index do |column, i|
    next unless column[:name].to_s.downcase.start_with?("number of rows ")

    value = cells[i]
    total += value.to_i unless value.nil?
  end
  total
end

.dml_status?(columns) ⇒ Boolean

Whether a result set is a DML status row rather than data. The protocol carries no statement type, so this goes by shape: DML answers with a single row whose every column is a "number of ..." counter. INSERT and DELETE report one, UPDATE adds "number of multi-joined rows updated", and MERGE reports both an inserted and an updated count.

Returns:

  • (Boolean)


466
467
468
469
470
471
472
473
# File 'lib/frostlake.rb', line 466

def dml_status?(columns)
  return false if columns.empty?

  columns.each do |column|
    return false unless column[:name].to_s.downcase.start_with?("number of ")
  end
  true
end

.format_literal(value) ⇒ Object



588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
# File 'lib/frostlake.rb', line 588

def format_literal(value)
  case value
  when nil then "NULL"
  when true then "TRUE"
  when false then "FALSE"
  when Integer then value.to_s
  when Float
    raise UsageError, "non-finite number #{value}" unless value.finite?

    value.to_s
  # A Ruby Time always carries a UTC offset, so it maps to TIMESTAMP_TZ;
  # casting to NTZ here silently discarded that offset.
  when Time then "'#{value.strftime('%Y-%m-%dT%H:%M:%S.%6N%:z')}'::TIMESTAMP_TZ"
  when DateTime then format_literal(value.to_time)
  when Date then "'#{value.strftime('%Y-%m-%d')}'::DATE"
  when String
    # A binary-encoded string is the deliberate marker for BINARY data.
    if value.encoding == Encoding::ASCII_8BIT
      "X'#{value.unpack1('H*').upcase}'"
    else
      encode_string(value)
    end
  when Symbol then encode_string(value.to_s)
  when Array then "[#{value.map { |element| format_literal(element) }.join(', ')}]"
  else
    if defined?(BigDecimal) && value.is_a?(BigDecimal)
      value.to_s("F")
    else
      raise UsageError, "unsupported bind type #{value.class}"
    end
  end
end

.idle_limit_for(argument, from_dsn) ⇒ Object

Zero switches the idle check off; anything else is seconds.



495
496
497
498
499
500
501
502
503
504
505
# File 'lib/frostlake.rb', line 495

def idle_limit_for(argument, from_dsn)
  given = argument.nil? ? from_dsn : argument
  return DEFAULT_SESSION_IDLE_LIMIT if given.nil?

  seconds = Float(given)
  raise UsageError, "session_idle_limit cannot be negative, got #{given}" if seconds.negative?

  seconds
rescue ArgumentError, TypeError
  raise UsageError, "session_idle_limit must be a number of seconds, got #{given.inspect}"
end

.monotonic_nowObject

A clock that cannot jump backwards over an idle connection.



490
491
492
# File 'lib/frostlake.rb', line 490

def monotonic_now
  Process.clock_gettime(Process::CLOCK_MONOTONIC)
end

.parse_json(text) ⇒ Object

Keeps every JSON number exact: the engine serializes fixed-point numerics from BigDecimal, and Float would round the digits away before convert ever sees them.



416
417
418
419
420
# File 'lib/frostlake.rb', line 416

def parse_json(text)
  return JSON.parse(text) unless defined?(BigDecimal)

  JSON.parse(text, decimal_class: BigDecimal)
end

.quote_ident(name) ⇒ Object

Always quoted. Leaving "unambiguous" names bare let through ones that cannot legally appear unquoted — 1ABC starts with a digit, SELECT is reserved — and quoting costs nothing: "NAME" and NAME name the same object, so only genuinely lower-case names are affected and those had to be quoted anyway.

Raises:



406
407
408
409
410
411
# File 'lib/frostlake.rb', line 406

def quote_ident(name)
  text = name.to_s
  raise UsageError, "identifier cannot be empty" if text.empty?

  "\"#{text.gsub('"', '""')}\""
end

.selects_session_state?(sql) ⇒ Boolean

A USE picks the database, schema, warehouse or role for the session. Once the caller has done that themselves, the DSN no longer describes where they are, so the driver stops putting its defaults back.

Returns:

  • (Boolean)


510
511
512
# File 'lib/frostlake.rb', line 510

def selects_session_state?(sql)
  /(\A|[;\n])\s*USE\s/i.match?(sql)
end

.substitute(sql, binds) ⇒ Object

-- client-side parameter binding ------------------------------------



543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
# File 'lib/frostlake.rb', line 543

def substitute(sql, binds)
  out = +""
  nxt = 0
  i = 0
  while i < sql.length
    ch = sql[i]
    if ch == "'"
      j = skip_string(sql, i)
      out << sql[i...j]
      i = j
    elsif ch == '"'
      j = skip_quoted(sql, i)
      out << sql[i...j]
      i = j
    elsif ch == "-" && sql[i + 1] == "-"
      j = skip_line(sql, i)
      out << sql[i...j]
      i = j
    elsif ch == "/" && sql[i + 1] == "*"
      stop = sql.index("*/", i + 2)
      j = stop.nil? ? sql.length : stop + 2
      out << sql[i...j]
      i = j
    elsif ch == "/" && sql[i + 1] == "/"
      j = skip_line(sql, i)
      out << sql[i...j]
      i = j
    elsif ch == "$" && sql[i + 1] == "$"
      j = skip_dollar_quoted(sql, i)
      out << sql[i...j]
      i = j
    elsif ch == "?"
      raise UsageError, "not enough bind values for placeholders" if nxt >= binds.length

      out << format_literal(binds[nxt])
      nxt += 1
      i += 1
    else
      out << ch
      i += 1
    end
  end
  out
end

.timeout_for(name, argument, from_dsn, fallback) ⇒ Object

An explicit argument wins over the DSN, which wins over the default.



529
530
531
532
533
534
535
536
537
538
539
# File 'lib/frostlake.rb', line 529

def timeout_for(name, argument, from_dsn, fallback)
  given = argument.nil? ? from_dsn : argument
  return fallback if given.nil?

  seconds = Float(given)
  raise UsageError, "#{name} must be positive, got #{given}" unless seconds.positive?

  seconds
rescue ArgumentError, TypeError
  raise UsageError, "#{name} must be a number of seconds, got #{given.inspect}"
end

Instance Method Details

#begin_transactionObject



260
261
262
263
264
265
266
# File 'lib/frostlake.rb', line 260

def begin_transaction
  @lock.synchronize do
    @autocommit = false
    execute("BEGIN")
  end
  nil
end

#closeObject



300
301
302
303
304
305
306
307
308
309
# File 'lib/frostlake.rb', line 300

def close
  @closed = true
  @lock.synchronize do
    @http.finish if @http.started?
  rescue IOError
    # Already gone; closing is still closing.
    nil
  end
  nil
end

#closed?Boolean

Returns:

  • (Boolean)


207
208
209
# File 'lib/frostlake.rb', line 207

def closed?
  @closed
end

#commitObject



268
269
270
271
272
273
274
# File 'lib/frostlake.rb', line 268

def commit
  @lock.synchronize do
    execute("COMMIT")
    @autocommit = true
  end
  nil
end

#execute(sql, binds = []) ⇒ Object

Executes one statement; returns a Result whose rows are hashes keyed by column name and whose row_count is the affected-row count for DML. A multi-statement string answers with its first result set — use execute_all for the rest.



239
240
241
# File 'lib/frostlake.rb', line 239

def execute(sql, binds = [])
  execute_all(sql, binds).first
end

#execute_all(sql, binds = []) ⇒ Object

Executes a statement string and returns every result set it produced, in order. A single statement gives a one-element array.



245
246
247
248
249
250
251
252
253
254
255
256
257
258
# File 'lib/frostlake.rb', line 245

def execute_all(sql, binds = [])
  check_open
  rendered = binds.empty? ? sql : self.class.substitute(sql, binds)
  # The pending USE statements and the statement itself have to reach the
  # session as one unit: another thread must not slip a query in between,
  # and two threads must not both try to shift the same pending entry.
  @lock.synchronize do
    restore_session_defaults
    round_trip(@pending_use.shift) until @pending_use.empty?
    results = shape_results(round_trip(rendered))
    @session_touched = true if self.class.selects_session_state?(sql)
    results
  end
end

#pingObject



222
223
224
225
226
227
228
229
230
231
232
233
# File 'lib/frostlake.rb', line 222

def ping
  check_open
  @lock.synchronize do
    response = begin
      @http.get("/api/health")
    rescue IOError, SocketError, SystemCallError, Timeout::Error => e
      raise ConnectionError, "cannot reach #{@host}:#{@port}: #{e.message}"
    end
    raise ConnectionError, "server unhealthy: HTTP #{response.code}" unless response.is_a?(Net::HTTPSuccess)
  end
  nil
end

#rollbackObject



276
277
278
279
280
281
282
# File 'lib/frostlake.rb', line 276

def rollback
  @lock.synchronize do
    execute("ROLLBACK")
    @autocommit = true
  end
  nil
end

#transactionObject

Runs the block inside BEGIN ... COMMIT, rolling back on any exception.



285
286
287
288
289
290
291
292
293
294
295
296
297
298
# File 'lib/frostlake.rb', line 285

def transaction
  begin_transaction
  result = yield self
  commit
  result
rescue StandardError
  begin
    rollback
  rescue StandardError
    # A failed rollback must not replace the exception that caused it.
    nil
  end
  raise
end

#use_dsn_defaultsObject

Applies the database and schema named in the DSN. connect calls this, so a name that does not exist is reported there rather than surfacing later on whatever query happens to run first.



214
215
216
217
218
219
220
# File 'lib/frostlake.rb', line 214

def use_dsn_defaults
  check_open
  @lock.synchronize do
    round_trip(@pending_use.shift) until @pending_use.empty?
  end
  nil
end