Class: Frostlake::Connection
- Inherits:
-
Object
- Object
- Frostlake::Connection
- Defined in:
- lib/frostlake.rb
Class Method Summary collapse
-
.boolean_for(name, argument, from_dsn, fallback) ⇒ Object
An explicit argument wins over the DSN, which wins over the default.
- .convert(value, data_type, scale = 0) ⇒ Object
-
.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.
-
.decode_hex(text) ⇒ Object
The engine renders binary as hex.
-
.dml_row_count(columns, cells) ⇒ Object
Total rows affected.
-
.dml_status?(columns) ⇒ Boolean
Whether a result set is a DML status row rather than data.
- .format_literal(value) ⇒ Object
-
.idle_limit_for(argument, from_dsn) ⇒ Object
Zero switches the idle check off; anything else is seconds.
-
.monotonic_now ⇒ Object
A clock that cannot jump backwards over an idle connection.
-
.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.
-
.quote_ident(name) ⇒ Object
Always quoted.
-
.selects_session_state?(sql) ⇒ Boolean
A USE picks the database, schema, warehouse or role for the session.
-
.substitute(sql, binds) ⇒ Object
-- client-side parameter binding ------------------------------------.
-
.timeout_for(name, argument, from_dsn, fallback) ⇒ Object
An explicit argument wins over the DSN, which wins over the default.
Instance Method Summary collapse
- #begin_transaction ⇒ Object
- #close ⇒ Object
- #closed? ⇒ Boolean
- #commit ⇒ Object
-
#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.
-
#execute_all(sql, binds = []) ⇒ Object
Executes a statement string and returns every result set it produced, in order.
-
#initialize(dsn, open_timeout: nil, read_timeout: nil, verify_ssl: nil, ca_file: nil, session_idle_limit: nil) ⇒ Connection
constructor
A new instance of Connection.
- #ping ⇒ Object
- #rollback ⇒ Object
-
#transaction ⇒ Object
Runs the block inside BEGIN ...
-
#use_dsn_defaults ⇒ Object
Applies the database and schema named in the DSN.
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.
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.
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_now ⇒ Object
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.
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.
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_transaction ⇒ Object
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 |
#close ⇒ Object
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
207 208 209 |
# File 'lib/frostlake.rb', line 207 def closed? @closed end |
#commit ⇒ Object
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 |
#ping ⇒ Object
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.}" end raise ConnectionError, "server unhealthy: HTTP #{response.code}" unless response.is_a?(Net::HTTPSuccess) end nil end |
#rollback ⇒ Object
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 |
#transaction ⇒ Object
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_defaults ⇒ Object
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 |