frostlake-ruby

A zero-dependency Ruby driver for Frostlake, speaking the engine's HTTP protocol against a running DatabaseHttpServer. Ruby ≥ 3.0, stdlib only (net/http, json, uri, date, time, and bigdecimal when it is available).

Installation

gem install frostlake

Or in a Gemfile:

gem "frostlake"

The database and schema named in the DSN are applied by Frostlake.connect, so a name that does not exist is reported there rather than surfacing later on whatever query happens to run first. They are quoted before they are sent, so a name that could not appear unquoted — one starting with a digit, or a reserved word — is passed through intact.

Engine version

Requires a Frostlake engine 0.0.7 or newer. Ask a running server which one it is with SELECT CURRENT_VERSION() — every release answers it, so the check works against any engine.

The driver versions independently of the engine: it speaks the HTTP protocol, not the jar, so this is a floor rather than a lockstep pin.

One behaviour does depend on the engine: a TIMESTAMP_TZ column only reports back the UTC offset it was given from engine 0.1.0 on. Against an older engine a bound Time still round-trips, but the offset comes back as +00:00.

Usage

require "frostlake"

conn = Frostlake.connect("frostlake://localhost:18082/MY_DB?schema=PUBLIC")

conn.execute("CREATE TABLE people (id INTEGER, name VARCHAR)")
result = conn.execute("INSERT INTO people VALUES (?, ?), (?, ?)", [1, "Ada", 2, "Grace"])
result.row_count # => 2

result = conn.execute("SELECT id, name FROM people WHERE id = ?", [1])
result.rows # => [{ "ID" => 1, "NAME" => "Ada" }]

conn.close

execute(sql, binds = []) returns a Frostlake::Result with columns, rows (hashes keyed by column name; the result is Enumerable over them), values and row_count (the affected-row count for DML). A failed statement raises Frostlake::Error carrying the engine's error message.

values is what the wire delivered: every cell, positionally, lined up with columns. rows is a view over it keyed by column name, built the first time you ask and then kept — so a caller that only reads values never pays for the hashes. Because it is keyed by name it cannot hold two columns called the same thing: a self-join reports ID twice and the later one wins, while values keeps both.

result = conn.execute("SELECT e.id, e.name, m.id, m.name FROM emp e JOIN emp m ON e.manager_id = m.id")
result.columns.map { |c| c[:name] }  # => ["ID", "NAME", "ID", "NAME"]
result.rows.first                    # => {"ID" => 1, "NAME" => "Ada"}
result.values.first                  # => [2, "Grace", 1, "Ada"]

Transactions

conn.transaction do
  conn.execute("INSERT INTO acc VALUES (2)")
end                          # commits; rolls back if the block raises

conn.begin_transaction       # or drive it by hand
conn.execute("...")
conn.commit                  # / conn.rollback

Bind values

Parameters are inlined client-side (? placeholders); placeholders inside string literals, quoted identifiers, comments and $$…$$ bodies are left alone. Too few bind values raises Frostlake::UsageError; surplus ones are ignored, matching Frostlake's other drivers.

Ruby value SQL literal
nil NULL
true / false TRUE / FALSE
Integer / Float / BigDecimal as written
String (UTF-8) / Symbol '…' (backslashes and quotes escaped)
String with ASCII-8BIT encoding X'hex' (binary marker)
Time / DateTime '…±hh:mm'::TIMESTAMP_TZ (a Ruby Time always carries an offset)
Date '…'::DATE
Array […] (elements formatted recursively)

Result types

Fixed-point NUMBER keeps the exact digits the engine sent: BigDecimal when the column has a scale, Integer when it does not (of any size — Ruby integers are arbitrary precision). FLOAT/DOUBLE/REAL are genuine binary floats and stay Float. BOOLEAN becomes true/false, DATE a Date, TIMESTAMP* a Time, BINARY a binary-encoded String; TIME and semi-structured values keep their wire shape as strings.

Each entry in columns is a hash of { name:, data_type:, scale: }, carrying the engine's own type name.

A result set arrives as one JSON body and is fully materialised — the driver holds every row in memory, and the protocol offers no cursor to page through a large SELECT. Bound the query rather than expecting the driver to stream it.

Exact decimals need bigdecimal, which ships with Ruby. If it cannot be loaded — a bundler setup on Ruby ≥ 3.4 without it in the Gemfile — those cells fall back to Float rather than the driver failing to load.

Several statements at once

execute returns the first result set. execute_all returns every one, in order:

sets = conn.execute_all("SELECT 1 AS a; SELECT 2 AS b;")
sets.length      # => 2
sets.last.rows   # => [{ "B" => 2 }]

If any statement in the string fails the whole call raises and no result sets come back — not even for the statements before it. The engine discards their effects too: an INSERT followed by a failing statement leaves nothing behind, whether the failure is a syntax error, a missing table or a division by zero.

Connection options

Every option can be given as a keyword or in the DSN query string, the keyword winning. Timeouts are in seconds and default to 10s to connect and 300s to wait for a statement.

Frostlake.connect(dsn, open_timeout: 5, read_timeout: 30)
Frostlake.connect("frostlake://localhost:18082/DB?open_timeout=5&read_timeout=30")

An https:// DSN verifies the server certificate. Point at your own authority with ca_file, or turn verification off for a self-signed server:

Frostlake.connect("https://localhost:8443/DB", ca_file: "/etc/ssl/my-ca.pem")
Frostlake.connect("https://localhost:8443/DB?verify_ssl=false")

The server authenticates nobody, so a DSN carrying user:password@ is rejected rather than having the credentials quietly dropped.

verify_ssl accepts true/false, yes/no or 1/0. The DSN query string accepts schema, open_timeout, read_timeout, verify_ssl, ca_file and session_idle_limit, and nothing else: an unknown parameter is refused rather than ignored, so a misspelled schema cannot quietly leave you in the wrong one. The same goes for verify_ssl and ca_file on a DSN that is not https — they are refused however they were spelled, since they would do nothing. The path names one database, so frostlake://host/db/extra is refused too.

Idle sessions

The engine drops a session after 30 minutes idle and then quietly builds a fresh one for the id the driver keeps sending. A connection left sitting therefore loses the database and schema it had selected, and nothing in the reply says so — the id you sent is echoed back either way, and /api/sessions reports only a count, so the driver cannot ask whether its session survived.

What it does instead: once a connection has been idle longer than session_idle_limit (1800 seconds by default, matching the engine), it re-applies the database and schema from the DSN before the next statement. It stops doing that the moment you run a USE of your own, since the DSN no longer describes where you are.

Frostlake.connect(dsn, session_idle_limit: 600)   # re-apply after ten idle minutes
Frostlake.connect("frostlake://host:18082/DB?session_idle_limit=0")  # never

Everything else a dropped session held — the warehouse, the role, session variables, an open transaction — is gone, and no client can restore it. If a connection may idle for long stretches, reconnecting is the dependable answer.

Errors

Everything the driver raises is a Frostlake::Error, so a single rescue still catches the lot. The subclass says which kind it was:

Class Raised when
Frostlake::ConnectionError the server is unreachable, unhealthy, or the request failed
Frostlake::QueryError the engine rejected the statement; the message is the engine's
Frostlake::UsageError the driver was misused: bad DSN, closed connection, unbindable value

Threads

A Connection is one socket and one server-side session, so statements on it are serialized and it is safe to share. A transaction is session state, though — don't drive one from several threads at once. Use a connection per thread if you want statements to actually run in parallel.

Running the tests

The integration tests boot a real server from the engine's compiled classes:

export JAVA_HOME=/path/to/jdk17
export FROSTLAKE_CLASSPATH="/path/to/frostlake/engine/target/classes:<engine deps>"
rake test        # or: ruby test/test_frostlake.rb

Without FROSTLAKE_CLASSPATH the integration tests skip themselves and only the substitution unit tests run.

Protocol

One POST /api/execute per statement with { sql, sessionId, autoCommit }; the server issues the sessionId on first contact and the driver echoes it back, so session state (current database/schema, transactions) persists across statements. GET /api/health backs Frostlake.connect's reachability check.

License

Apache-2.0 — see LICENSE.