A DBI backend for Frostlake, speaking the engine’s HTTP
protocol against a running DatabaseHttpServer. Plain R
package frostlake: transport is curl, parsing
is jsonlite, the surface is the standard DBI generics — so
dbConnect, dbGetQuery,
dbWriteTable and everything that rides on DBI work as they
do on any other backend.
Requires a Frostlake engine 0.2.0 or newer. Ask a
running server which one it is with
SELECT CURRENT_VERSION() — every release answers it. 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.
Recovering from a lost session and releasing the session on disconnect
need engine 0.1.0; see Session lifetime.
library(DBI)
con <- dbConnect(frostlake::frostlake(),
host = "localhost", port = 18082,
dbname = "MY_DB", schema = "PUBLIC")
# or: dbConnect(frostlake::frostlake(), url = "frostlake://localhost:18082/MY_DB?schema=PUBLIC")
dbExecute(con, "CREATE TABLE people (id INTEGER, name VARCHAR)")
dbExecute(con, "INSERT INTO people VALUES (?, ?)", params = list(1L, "Ada")) # 1
dbGetQuery(con, "SELECT id, name FROM people WHERE id = @id", params = list(id = 1L))
dbWriteTable(con, "SCORES", data.frame(id = 1:3, score = c(9.5, 8, 7)))
dbReadTable(con, "SCORES")
dbWithTransaction(con, {
dbExecute(con, "UPDATE people SET name = 'Ada L.' WHERE id = 1")
})
dbDisconnect(con)Failed statements raise a condition of class
frostlake_error carrying the engine’s message. A statement
the engine refused also carries frostlake_sql_error, so
tryCatch(…, frostlake_sql_error = …) tells a refused
statement from an unreachable server or a failed request; a session the
engine lost along with a transaction or context carries
frostlake_session_lost (see Session lifetime). The text is sent as
written — an empty statement included, which the engine refuses with its
own message. dbConnect applies the database and schema
eagerly, so an unknown database fails at connect rather than on the
first statement. A plain dbname/schema value
is sent bare and resolves case-insensitively, as on Snowflake —
dbname = "my_db" finds MY_DB; wrap the value
in double quotes itself (dbname = '"my_db"') to match an
exact-case name.
Parameters are inlined client-side. Unnamed elements of
params fill ? placeholders positionally, in
order; named elements fill @name/:name
placeholders by name (a @/: prefix on the
element’s own name is ignored, and a name may repeat). The two styles
mix in one statement.
Placeholders inside string literals, quoted identifiers,
$$…$$ bodies and
--/////* */ comments are left
alone; :: is always the cast operator, and a marker
directly after the end of an operand — a name, number or
$variable, a closing ), ] or
}, the closing quote of a literal or quoted identifier, or
a ? — is never a bind, so stage references
(COPY INTO t FROM @my_stage), VARIANT paths
(v:address:city, {'a':1}:a,
'…':a, ?:a), casts (v::date) and
scripting assignment (LET x := 1) pass through untouched. A
marker after a space still binds (WHERE v = :a). A named
element may go unused, but a positional value that no
? consumed is an error rather than a silent drop. With
placeholders and no params, dbSendQuery defers
execution until dbBind; bound vectors of equal length run
the statement once per index (dbAppendTable semantics).
A Snowflake Scripting block — text that opens with
DECLARE, or with a BEGIN that starts a block
rather than a transaction — owns its ? placeholders: they
are a cursor’s parameters, bound by OPEN … USING, so they
reach the engine untouched. Bind values into a block by name
(:name).
| R value | SQL literal |
|---|---|
NULL / NA |
NULL |
| logical | TRUE / FALSE |
| integer, double | as written (%.17g, invariant) |
| character / factor | '…' (backslashes and quotes escaped) |
Date |
'…'::DATE |
POSIXct |
UTC clock time as '…'::TIMESTAMP_NTZ |
difftime |
'…'::TIME |
| raw vector | X'hex' |
| other vectors, lists | […] (elements formatted recursively) |
The R type of each column is decided once from the values it actually
carries, so it holds for every row (the engine reports a plain
INTEGER as NUMBER(38,0), so the type has to
follow the data, not the precision):
NUMBER → integer, widening to double past
.Machine$integer.max and to the exact digit string past
2^53 (beyond ±2^63 the JSON parser overflows to double — a known
limitation);NUMBER, FLOAT → double;
BOOLEAN → logical;DATE → Date; TIME →
difftime;TIMESTAMP* → POSIXct labelled
UTC — a trailing wire offset (… +0200) is
honoured, so the instant never depends on the client machine’s timezone.
A POSIXct holds microseconds; a column carrying a finer
fraction (0.1.0+ engines send up to nine digits) stays character rather
than rounding the nanoseconds away;BINARY → a list of raw vectors (NULL
elements for SQL NULLs);VARIANT/OBJECT/ARRAY included,
arrives as character.An unparseable cell drops its whole column to character, so no row silently lies.
dbColumnInfo reports name,
type, precision, scale,
length and nullable. length is
the declared width of a text (characters) or binary (bytes) column — the
type’s maximum, 16777216, for an unbounded one, and the
number the account reports as both that column’s precision and its
display size. Every other type, and every server that predates the
field, reads NA_integer_ rather than 0.
Engines from 0.1.0 report each result’s affected-row count outright,
and dbExecute/dbGetRowsAffected use that
answer — so a query whose column merely happens to be named like a count
is not mistaken for DML. Against an older engine the count is read from
the result itself: DML answers a single-row result whose count columns
are named “number of rows …” — one column for INSERT/DELETE, a second
(always-zero) “number of multi-joined rows updated” column for UPDATE,
one column per action for MERGE — and the driver sums every “number of
rows …” column, the rule the engine’s own JDBC driver applies. Either
way UPDATE and MERGE report their true counts, and DDL and SELECT report
0. On 0.1.0+ engines DDL answers a one-row status message
(Table T successfully created.), which
dbGetQuery shows and dbExecute ignores.
An HTTP session starts expecting one statement per
request, as on Snowflake, and newer engines refuse a request
carrying more. To run several statements in one text, declare the count
— exact, or 0 for any number:
dbGetQuery(con, "SELECT 1 AS a; SELECT 2 AS b", multi_statement_count = 2)
dbExecute(con, "INSERT INTO t VALUES (1); INSERT INTO t VALUES (2), (3)",
multi_statement_count = 0) # 3The parameter rides
dbSendQuery/dbGetQuery/dbExecute
(older engines ignore it). Queries materialize the
first result set; affected-row counts sum across all of
them.
A connection is one session on the engine, and the session is where
the current database and schema, session variables,
ALTER SESSION settings, temporary tables and an open
transaction live. The engine ends a session after 30 minutes idle, when
it is released, or when the server restarts. From engine 0.1.0 on every
answer says whether the session it ran in is new
(newSession), and the driver takes the first answer that
names a session as the sign of which kind of engine it is talking
to.
newSession it also
sends requireSession: true, so a session the engine no
longer holds is refused (HTTP 404) instead of being quietly replaced by
a fresh one in which the statement would run somewhere else. An older
engine is sent neither that field nor the release below.USE, SET/UNSET,
ALTER SESSION, a temporary object or a
CREATE/DROP of a database or schema, running
the statement again could put it somewhere its author did not intend, so
the driver raises frostlake_session_lost instead, saying
which. Either way the connection stays usable: its next statement starts
a fresh session on the connection’s database and schema. A transaction
dbBegin() opened is over by then — the engine rolled it
back when it let the session go — so dbRollback() has
nothing left to do and succeeds, dbCommit() raises
frostlake_session_lost, and
dbWithTransaction() reports the lost session itself.dbDisconnect() sends
DELETE /api/sessions/{id}, which ends the session and rolls
back a transaction it left open. It is best effort, bounded by the
connection’s timeout or five seconds, whichever is shorter,
and never raises; disconnecting again only warns. An engine older than
0.1.0 has no such endpoint, so there an open transaction gets a
ROLLBACK instead and the session lingers until the engine’s
idle expiry. A dbConnect() whose database or schema the
engine refuses releases the session it had started before raising.tryCatch(
dbWithTransaction(con, dbExecute(con, "INSERT INTO acc VALUES (2)")),
frostlake_session_lost = function(e) {
# the transaction and anything set up on the session are gone; start the
# unit of work over -- the connection itself is fine
})The integration tests boot a real server from the engine’s compiled classes:
export JAVA_HOME=/path/to/jdk17 # 17+; the engine's class-file floor
export FROSTLAKE_CLASSPATH="/path/to/frostlake-db.jar:<engine deps>"
Rscript -e 'testthat::test_local("path/to/frostlake-R")'Without FROSTLAKE_CLASSPATH the engine-backed tests
report as skipped — never as passed — so a run with no
server cannot be mistaken for a green suite. The unit tests
(substitution, literals, wire parsing driven from wire-shaped JSON,
quoting, DSN, and session handling over a scripted transport) still run
and cover most of the driver on their own. R CMD check
passes clean (Status: OK).
check/testkit-suites.R replays the engine’s
language-neutral test corpus through the driver. Point
FL_CORPUS at the testkit directory of a frostlake checkout
(an absolute path) and the test run replays it too, against the engine
configured as above; without FL_CORPUS the corpus reports
as skipped:
FL_CORPUS=/path/to/frostlake/engine/src/test/resources/testkit \
Rscript -e 'testthat::test_local("path/to/frostlake-R")'One POST /api/execute per statement with
{ sql, sessionId, autoCommit } (plus
multiStatementCount when declared, and
requireSession once the engine has shown it reports
newSession); the server issues the sessionId
on first contact and the connection echoes it back, so session state
(current database/schema, transactions) persists across statements.
GET /api/health backs dbConnect’s reachability
check, and DELETE /api/sessions/{id} releases the session
on dbDisconnect (see Session
lifetime). (There is no connection pooling, and the protocol carries
no credentials, so authentication is not available over this
transport.)