frostlake-elixir

A dependency-free Elixir driver for Frostlake, speaking the engine's HTTP protocol against a running DatabaseHttpServer. Elixir 1.14+ on OTP 25+, :gen_tcp and :ssl only — no JVM, no NIFs, and nothing in mix.exs to fetch.

Engine version

Requires a Frostlake engine 0.0.7 or newer, and is verified against 0.0.7 and 0.1.0. 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.

Installation

From Hex:

def deps do
[{:frostlake, "~> 0.1.0"}]
end

A script or a Livebook can pull it in with Mix.install([{:frostlake, "~> 0.1.0"}]).

Usage

{:ok, conn} = Frostlake.connect("frostlake://localhost:18082/MY_DB?schema=PUBLIC")
{:ok, _} = Frostlake.execute(conn, "CREATE TABLE people (id INTEGER, name VARCHAR)")
{:ok, inserted} =
Frostlake.execute(conn, "INSERT INTO people VALUES (?, ?), (?, ?)", [1, "Ada", 2, "Grace"])
inserted.update_count
#=> 2
{:ok, result} = Frostlake.execute(conn, "SELECT id, name FROM people WHERE id = ?", [1])
result.rows
#=> [[1, "Ada"]]
:ok = Frostlake.close(conn)

connect/2 contacts the server before it returns: it calls the health endpoint and applies the scope the DSN names, so a database that does not exist is reported there rather than surfacing later on whichever query happened to run first.

Every call answers {:ok, value} or {:error, exception}; execute!/4 and execute_all!/4 raise instead.

In a supervision tree

children = [
{Frostlake.Connection, dsn: "frostlake://localhost:18082/MY_DB", name: MyApp.DB}
]

Statements then run as Frostlake.execute(MyApp.DB, sql). A supervised connection is not handed a statement while it starts up, so it does not contact the server in init/1; the DSN's scope is applied ahead of the first statement instead.

DSN

frostlake://host[:port][/DATABASE][?param=value&…]

http:// and https:// are accepted too and mean the same thing. Omitting the port means the engine's own default, 18082; for http/https it means their standard ports.

Parameter Meaning Default
schema schema to USE on the session
role role to USE on the session
warehouse warehouse to USE on the session
timeout how long one statement may take; 0 removes the bound 5m
connectTimeout how long to wait for the socket 10s
idleLimit how long a connection may idle before its scope is re-applied; 0 switches the check off 30m
tls true to speak HTTPS — an https:// DSN does the same false

A database, schema, role or warehouse name means what it would mean written in SQL. A plain name folds to upper case, so my_db selects MY_DB; a name wrapped in double quotes — %22my_db%22 in the URL — keeps its exact case; anything else, such as my db, is quoted exactly as given.

Durations are written as a bare number of seconds or with a ms/s/m/h suffix. Each parameter may also be spelled the way Elixir reads more naturally — connect_timeout, idle_limit — and either spelling means the same thing. An unknown parameter is an error rather than a silent no-op, and so is a username or password: the engine's HTTP API has no authentication to hand them to, and quietly dropping a password is worse than saying so.

Every parameter can also be passed to connect/2 directly, where an explicit option outranks the DSN and durations may be given as milliseconds:

Frostlake.connect("frostlake://localhost", database: "MY_DB", timeout: 30_000)

:database, :schema, :role, :warehouse, :timeout, :connect_timeout, :idle_limit, :tls, :verify_certificate, :cacerts, :cacertfile and :name are the options.

Results

execute/4 answers a Frostlake.Result:

Field or function What it holds
columns a Frostlake.Column per column: name, declared type, nullability, precision, scale
rows every row as a list of cells, positionally aligned with columns — the lossless view
Result.to_maps/1 each row keyed by column name
Result.value/1 the first cell of the first row, for a single-value query
num_rows rows returned, or rows affected for DML
update_count rows affected by DML, or -1 when the statement returned data
counters the raw number of rows … counters behind update_count

A map cannot represent two columns called the same thing — a self-join reports ID twice and the later one wins — which is why rows stays positional and to_maps/1 is a function you call rather than a field.

A statement string holding several ;-separated statements answers with one result each: execute_all/4 returns them all, and execute/4 hands back the first. Such a string says how many statements it holds, the way Snowflake's own drivers do, and engine 0.1.0 refuses any other number before running any of it:

Frostlake.execute_all(conn, "INSERT INTO t VALUES (1); SELECT * FROM t", [], multi_statement_count: 2)

0 accepts any number. Without the option the session's MULTI_STATEMENT_COUNT decides: it starts at 1, and ALTER SESSION SET MULTI_STATEMENT_COUNT = 0 lifts it for the rest of the session. Engines before 0.1.0 run any number and ignore the option.

Bind values

Parameters are inlined client-side — the protocol has no server-side binding — with the same rules as Frostlake's JDBC driver. A ? inside a string literal, quoted identifier, $$…$$ body or comment is never a placeholder, and the argument count has to match exactly whenever arguments are supplied. With no arguments at all the markers pass through to the server: a ? is then a Snowflake Scripting cursor placeholder bound by OPEN c USING (...), and :name a Scripting variable.

Elixir value SQL literal
nil NULL
true / false TRUE / FALSE
integer the digits, exactly, at any width
float the shortest round-tripping form
:nan, :infinity, :neg_infinity 'NaN'::FLOAT and friends
binary '…', backslashes and quotes escaped
{:binary, bytes} X'hex'
%Date{} '…'::DATE
%Time{} '…'::TIME
%NaiveDateTime{} '…'::TIMESTAMP_NTZ
%DateTime{} '…'::TIMESTAMP_TZ, carrying its offset
%Decimal{} the digits, when the optional Decimal package is loaded
list […], elements formatted recursively
map {'key': …}, values formatted recursively

A string and a blob are the same type in Elixir, so {:binary, bytes} is the deliberate marker for BINARY. A plain binary is text, and one that is not valid UTF-8 is refused rather than guessed at — it is far more often a blob that forgot its tag than a broken string.

Positional parameters come as a list; named ones as a map or keyword list, matched case-insensitively and in any order:

Frostlake.execute(conn, "SELECT :a + :b AS total", a: 2, b: 40)

A :: cast, a := assignment and a :1 positional reference are never parameters — and neither is Snowflake's VARIANT path access: a colon glued to the end of an expression (v:field, PARSE_JSON('…'):k, "V":k) reads a field, so a bind marker has to follow an operator, comma or keyword boundary. With no arguments at all, colon references pass through to the server untouched, because that is what Snowflake Scripting variables look like (EXECUTE IMMEDIATE :v, IFF(:flag, …)).

Which style a statement uses is what decides how a list is read, rather than what the list looks like: [{:binary, <<1>>}] is one positional argument and also a perfectly good keyword list, and only the statement can settle it.

Types coming back

SQL type Elixir type
integral NUMBER, INTEGER and friends integer, exact at any width
fractional NUMBER, FLOAT, DOUBLE, REAL float
VARCHAR and the text types String.t
BOOLEAN boolean
BINARY a binary of the decoded bytes
DATE Date
TIME Time
TIMESTAMP, TIMESTAMP_NTZ, DATETIME NaiveDateTime
TIMESTAMP_LTZ, TIMESTAMP_TZ DateTime — the instant, in UTC
VARIANT, OBJECT, ARRAY String.t, the value's JSON text

A semi-structured cell is left as JSON text for the caller to decode. From engine 0.1.0 on that text is what Snowflake's own drivers hand back, so a VARIANT holding a string arrives with its quotes — "a" rather than a.

A NUMBER(38,0) holds integers no 64-bit word can name. The driver's JSON layer decodes an integer literal to an Elixir integer rather than a float, so those arrive exact.

A fractional NUMBER becomes a float, which is not exact past 15 significant digits: Elixir has no decimal of its own, and a driver with no dependencies cannot borrow one. Read such a column as text — TO_VARCHAR(amount) — when the last digit matters.

A DATE and a TIMESTAMP_NTZ are wall clocks with no zone of their own, and Date and NaiveDateTime have none either, so their fields read back exactly as stored rather than being shifted by whatever zone the host is in. A TIMESTAMP_TZ names an instant, and arrives as a DateTime in UTC — the same choice DateTime.from_iso8601/1 makes for a string carrying an offset.

A FLOAT that is not a number arrives as :nan, :infinity or :neg_infinity, because an Elixir float has no way to spell those. They go back the same way.

Transactions

Frostlake.transaction(conn, fn c ->
Frostlake.execute!(c, "INSERT INTO acc VALUES (1)")
end)

The helper commits when the body returns and answers {:ok, value}; it rolls back and hands back the error when the body answers {:error, reason}; and it rolls back and re-raises when the body raises, throws or exits. begin/2, commit/2 and rollback/2 are there for hand-rolled control. The engine offers read committed.

A transaction lives on the session, not on the closure, so anything else run on the same connection meanwhile joins it. Give a transaction its own connection if that is not what you want.

Errors

Three exceptions, by which side the failure came from — the distinction a caller actually branches on:

QueryError.statement holds the rendered SQL. Because binding is client-side, that means every parameter inlined — a bound password or card number appears in it verbatim. Exception.message/1 carries none of it, so log that freely and treat :statement as sensitive.

Sessions and concurrency

One HTTP session per connection process. Statements are serialized in call order, so several processes may use one connection safely and stay on one session — which is what keeps USE, session variables and an open transaction carrying from one statement to the next.

tasks = [
Task.async(fn -> Frostlake.execute(conn, "INSERT INTO t VALUES (1)") end),
Task.async(fn -> Frostlake.execute(conn, "INSERT INTO t VALUES (2)") end)
]
Task.await_many(tasks) # serialized, one session

The socket is kept alive between statements and dropped by close/1. A statement that reached the wire is never sent a second time: the only recovery the driver performs is for a reused socket whose write failed outright, where nothing was transmitted at all. A socket the server closed while it sat idle — which the engine's own HTTP server does — is spotted before a request is written to it, so the caller never sees it happen.

For a pool, put several connections under your own supervisor and pick between them; the driver ships no pool of its own, because a connection is one session and pooling sessions is a policy question rather than a transport one.

Known limitations

Tests

The unit tests need nothing installed — no engine, no JVM. They cover the JSON codec, the DSN parser, the SQL scanner, parameter binding, value conversion, and the transport itself against a scriptable fake server that produces the answers a healthy engine never gives — a proxy's error page, a socket dropped between statements, a reply that never comes:

mix test

126 tests and a doctest, no failures, and the tests that need an engine are excluded rather than passing on a stub.

The integration tests additionally boot a real DatabaseHttpServer from an engine classpath — the engine jar and its dependency jars, joined with : (; on Windows):

JAVA_HOME=/path/to/jdk17 FROSTLAKE_CLASSPATH="<engine jar>:<dependency jars>" mix test

Against engines 0.0.7 and 0.1.0 that run passes with no failures: 27 integration tests on top of the unit tests, every statement travelling connect → HTTP → DatabaseHttpServer.

The engine the tests boot is pinned to a directory of that run's own (_build/engine-<port>, emptied before boot), because a default-configured engine persists its catalog and its internal stages under the user's home directory — consecutive runs would otherwise inherit each other's warehouses and tables, and would walk over whatever engine the developer runs for themselves.

License

Apache-2.0 — see LICENSE.