frostlake 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +139 -21
  3. data/lib/frostlake.rb +395 -28
  4. metadata +1 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bb09cf620d3baab58a71403524ea8a8edc880fc52597196c2e6e10afa4507ad8
4
- data.tar.gz: 8eefe45f34075e8300cf7964588a0e8b7891da9158c9110c79aa9db5a081280d
3
+ metadata.gz: 3cf1e48b23f2ced0c5ddea38ec4df19cb621c1ea10138f0a608c47f7781feebc
4
+ data.tar.gz: 1fe5fbed7c4ac104666ebe75a48ed8d4b27b6f739177ea4989d69cc89ed8c1dd
5
5
  SHA512:
6
- metadata.gz: df21cb9b7ec8a9c37dbe062d705e6f1cdbebc11640c45bcb8db0f2250ef5b9ad203dee933980a802a455efcf2ba1b798ccf1bfee8559205d9371251192e13d49
7
- data.tar.gz: 16752c3e007ff4c046b5b57762039f0db36ef7e24aa3d9b144b144ebee9cd4f96950d79a1a1f56c6b198e5e643231564fbb1406b5805ce5ea568051acfce3c1b
6
+ metadata.gz: ce4592c00895efb401c086b7c170100820ddb5036928692621227e85f523faf72834bd46c9c53896ec942abc9d2383bf57759f5e00db7a5b8a985feb2fe56b80
7
+ data.tar.gz: f67792bc1e5beddc690ba2aeff1f14569eedd49eeffafe62b10e635750c99b33ab273f612e497c4f987253153d258f33c67a6b952b653fdbb89ebee66df7a9e4
data/README.md CHANGED
@@ -24,7 +24,7 @@ passed through intact.
24
24
 
25
25
  ## Engine version
26
26
 
27
- Requires a Frostlake engine **0.0.7 or newer**. Ask a running server which one it is with
27
+ Requires a Frostlake engine **0.2.0 or newer**. Ask a running server which one it is with
28
28
  `SELECT CURRENT_VERSION()` — every release answers it, so the check works against any engine.
29
29
 
30
30
  The driver versions independently of the engine: it speaks the HTTP protocol, not
@@ -32,7 +32,8 @@ the jar, so this is a floor rather than a lockstep pin.
32
32
 
33
33
  One behaviour does depend on the engine: a `TIMESTAMP_TZ` column only reports back the
34
34
  UTC offset it was given from engine **0.1.0** on. Against an older engine a bound `Time`
35
- still round-trips, but the offset comes back as `+00:00`.
35
+ still round-trips, but the offset comes back as `+00:00`. Recovering from a lost session
36
+ and releasing the session on close need **0.1.0** too; see [Session lifetime](#session-lifetime).
36
37
 
37
38
  ## Usage
38
39
 
@@ -81,6 +82,13 @@ conn.execute("...")
81
82
  conn.commit # / conn.rollback
82
83
  ```
83
84
 
85
+ `begin_transaction` sends `BEGIN` with autocommit off, and the connection leaves
86
+ autocommit only once the engine has opened the transaction. A begin that fails — refused,
87
+ unanswered or unreadable, lost with its session, or never sent because a `USE` queued
88
+ ahead of it was refused — leaves the connection in autocommit, so the statements after it
89
+ commit as they run. `transaction` follows a begin that fails with a best-effort rollback
90
+ as well, in case a `BEGIN` whose answer was lost did open a transaction.
91
+
84
92
  ### Bind values
85
93
 
86
94
  Parameters are inlined client-side (`?` placeholders); placeholders inside string
@@ -99,6 +107,12 @@ Frostlake's other drivers.
99
107
  | `Date` | `'…'::DATE` |
100
108
  | `Array` | `[…]` (elements formatted recursively) |
101
109
 
110
+ A bound `Time` goes into a `TIMESTAMP_TZ` column as it is. A `TIMESTAMP_NTZ` or
111
+ `TIMESTAMP_LTZ` column refuses it while compiling, as the account refuses any
112
+ `TIMESTAMP_TZ` written into one (`expecting TIMESTAMP_NTZ(9) but got TIMESTAMP_TZ(9)`),
113
+ so cast the bind there: `CAST(? AS TIMESTAMP_NTZ)` keeps the wall clock the `Time` was
114
+ written with, and `CAST(? AS TIMESTAMP_LTZ)` keeps its instant.
115
+
102
116
  ### Result types
103
117
 
104
118
  Fixed-point `NUMBER` keeps the exact digits the engine sent: `BigDecimal` when the
@@ -108,8 +122,23 @@ arbitrary precision). `FLOAT`/`DOUBLE`/`REAL` are genuine binary floats and stay
108
122
  `BINARY` a binary-encoded `String`; `TIME` and semi-structured values keep their
109
123
  wire shape as strings.
110
124
 
111
- Each entry in `columns` is a hash of `{ name:, data_type:, scale: }`, carrying the
112
- engine's own type name.
125
+ A `TIMESTAMP_NTZ` is a wall clock with no zone of its own, so it comes back as a `Time`
126
+ flagged UTC (`utc?` is true) whose fields read back exactly as stored, whatever zone the
127
+ host is in; a column declared `DATETIME`, or `TIMESTAMP` under the default mapping, is
128
+ one. Read in the host's local zone instead, a wall clock that zone skips would move:
129
+ `2024-03-31 01:30` does not exist in London, whose clocks go from 01:00 straight to 02:00
130
+ that night, so it would come back as `02:30 +0100`. The instant such a `Time` names is its
131
+ wall clock read as UTC, the one the engine's own epoch arithmetic
132
+ (`DATE_PART(EPOCH_SECOND, …)`) gives the value, and written back through
133
+ `CAST(? AS TIMESTAMP_NTZ)` it stores the wall clock it was read with. `TIMESTAMP_LTZ` and
134
+ `TIMESTAMP_TZ` carry an offset on the wire and come back as a `Time` at that instant and
135
+ offset.
136
+
137
+ Each entry in `columns` is a hash of `{ name:, data_type:, scale:, length: }`, carrying
138
+ the engine's own type name. `length` is the width a text or binary column was declared
139
+ with — characters for `VARCHAR(9)`, bytes for `BINARY(5)`, and the maximum (16777216 /
140
+ 8388608) for one declared without a width. Every other type reports `nil`: the server
141
+ sends no width for it, and `nil` is that, not a width of `0`.
113
142
 
114
143
  A result set arrives as one JSON body and is fully materialised — the driver holds
115
144
  every row in memory, and the protocol offers no cursor to page through a large
@@ -121,14 +150,33 @@ a bundler setup on Ruby ≥ 3.4 without it in the Gemfile — those cells fall b
121
150
 
122
151
  ### Several statements at once
123
152
 
153
+ A request carries one statement unless the session asks for more, as on the account, so a pack
154
+ sent without asking is refused with `Actual statement count 2 did not match the desired
155
+ statement count 1.` Ask with `ALTER SESSION SET MULTI_STATEMENT_COUNT = n`, or `0` for any
156
+ number.
157
+
124
158
  `execute` returns the first result set. `execute_all` returns every one, in order:
125
159
 
126
160
  ```ruby
161
+ conn.execute("ALTER SESSION SET MULTI_STATEMENT_COUNT = 0")
127
162
  sets = conn.execute_all("SELECT 1 AS a; SELECT 2 AS b;")
128
163
  sets.length # => 2
129
164
  sets.last.rows # => [{ "B" => 2 }]
130
165
  ```
131
166
 
167
+ A call can declare its own count instead of asking the session, with the
168
+ `multi_statement_count:` keyword on `execute` and `execute_all`:
169
+
170
+ ```ruby
171
+ sets = conn.execute_all("SELECT 1 AS a; SELECT 2 AS b;", [], multi_statement_count: 2)
172
+ ```
173
+
174
+ The count says how many statements that one call carries, `0` for any number. It
175
+ travels with that request and outranks the session's `MULTI_STATEMENT_COUNT` for it,
176
+ but changes no session state — nothing to save and put back, and a connection shared
177
+ between threads is unaffected. Left out, nothing is sent and the session's value
178
+ decides, which is 1 until it is told otherwise.
179
+
132
180
  If any statement in the string fails the whole call raises and no result sets come
133
181
  back — not even for the statements before it. The engine discards their effects
134
182
  too: an `INSERT` followed by a failing statement leaves nothing behind, whether the
@@ -164,29 +212,61 @@ same goes for `verify_ssl` and `ca_file` on a DSN that is not `https` — they a
164
212
  refused however they were spelled, since they would do nothing. The path names one
165
213
  database, so `frostlake://host/db/extra` is refused too.
166
214
 
167
- ### Idle sessions
215
+ ### Session lifetime
216
+
217
+ A connection is one session on the engine, and the session is where the current
218
+ database and schema, session variables, `ALTER SESSION` settings, temporary tables and
219
+ an open transaction live. The engine ends a session after 30 minutes idle, when it is
220
+ released, or when the server restarts. From engine 0.1.0 on every answer says whether
221
+ the session it ran in is new (`newSession`), and the driver takes the first answer that
222
+ names a session as the sign of which kind of engine it is talking to.
223
+
224
+ **What is sent.** Every request after the first names the session. To an engine that
225
+ reports `newSession` it also sends `requireSession: true`, so a session the engine no
226
+ longer holds is refused (HTTP 404) instead of being quietly replaced by a fresh one in
227
+ which the statement would run somewhere else. An older engine is sent neither that
228
+ field nor the release below.
229
+
230
+ **After a lost session.** The refused statement did not run. If the lost session held
231
+ nothing a fresh one lacks, the driver starts a fresh session, puts the DSN's database and
232
+ schema back on it, and sends the statement once more; if that is refused too, it raises.
233
+ If the lost session held an open transaction, or context set up with `USE`,
234
+ `SET`/`UNSET`, `ALTER SESSION`, a temporary object or a `CREATE`/`DROP` of a database or
235
+ schema, running the statement again could put it somewhere its author did not intend, so
236
+ the driver raises `Frostlake::SessionLostError` instead, saying which. Either way the
237
+ connection stays usable: its next statement starts a fresh session on the DSN's database
238
+ and schema.
168
239
 
169
- The engine drops a session after 30 minutes idle and then quietly builds a fresh one
170
- for the id the driver keeps sending. A connection left sitting therefore loses the
171
- database and schema it had selected, and **nothing in the reply says so** — the id
172
- you sent is echoed back either way, and `/api/sessions` reports only a count, so the
173
- driver cannot ask whether its session survived.
240
+ ```ruby
241
+ begin
242
+ conn.execute("INSERT INTO acc VALUES (2)")
243
+ rescue Frostlake::SessionLostError
244
+ # The transaction and anything set up on the session are gone; start the unit of
245
+ # work over. The connection itself is fine.
246
+ end
247
+ ```
174
248
 
175
- What it does instead: once a connection has been idle longer than
176
- `session_idle_limit` (1800 seconds by default, matching the engine), it re-applies
177
- the database and schema from the DSN before the next statement. It stops doing that
178
- the moment you run a `USE` of your own, since the DSN no longer describes where you
179
- are.
249
+ **Close.** `close` sends `DELETE /api/sessions/{id}`, which ends the session and rolls
250
+ back a transaction it left open. It is best effort and bounded (five seconds to connect
251
+ and five to be answered, or the connection's own timeouts when they are shorter), and it
252
+ never raises. Closing again sends nothing. An engine older than 0.1.0 has no such
253
+ endpoint, so it is not asked, and the session lingers until its idle expiry.
254
+
255
+ **Older engines.** Before 0.1.0 the engine quietly builds a fresh session for the id the
256
+ driver keeps sending, and nothing in the reply says so. Against such an engine a
257
+ connection that has been idle longer than `session_idle_limit` (1800 seconds by default,
258
+ matching the engine) re-applies the database and schema from the DSN before the next
259
+ statement. It stops doing that the moment you run a `USE` of your own, since the DSN no
260
+ longer describes where you are. Everything else a dropped session held — the warehouse,
261
+ the role, session variables, an open transaction — is gone, and no client can restore
262
+ it. An engine that reports `newSession` has no need of the timer, and the driver leaves
263
+ it off there.
180
264
 
181
265
  ```ruby
182
266
  Frostlake.connect(dsn, session_idle_limit: 600) # re-apply after ten idle minutes
183
267
  Frostlake.connect("frostlake://host:18082/DB?session_idle_limit=0") # never
184
268
  ```
185
269
 
186
- Everything else a dropped session held — the warehouse, the role, session variables,
187
- an open transaction — is gone, and no client can restore it. If a connection may idle
188
- for long stretches, reconnecting is the dependable answer.
189
-
190
270
  ### Errors
191
271
 
192
272
  Everything the driver raises is a `Frostlake::Error`, so a single rescue still catches
@@ -195,6 +275,7 @@ the lot. The subclass says which kind it was:
195
275
  | Class | Raised when |
196
276
  | --- | --- |
197
277
  | `Frostlake::ConnectionError` | the server is unreachable, unhealthy, or the request failed |
278
+ | `Frostlake::SessionLostError` | a `ConnectionError`: the engine no longer holds the session, and the transaction or context it held cannot be put back; the statement did not run |
198
279
  | `Frostlake::QueryError` | the engine rejected the statement; the message is the engine's |
199
280
  | `Frostlake::UsageError` | the driver was misused: bad DSN, closed connection, unbindable value |
200
281
 
@@ -218,11 +299,48 @@ rake test # or: ruby test/test_frostlake.rb
218
299
  Without `FROSTLAKE_CLASSPATH` the integration tests skip themselves and only the
219
300
  substitution unit tests run.
220
301
 
302
+ ## Testkit corpus runner
303
+
304
+ `testkit_runner.rb` replays the engine's testkit corpus — the language-neutral JSON
305
+ suites in `frostlake/engine/src/test/resources/testkit/suites`, format in the `SCHEMA.md`
306
+ beside them — through this driver, and compares every cell as the driver hands it back.
307
+ `FL_CORPUS` names that testkit directory, best as an absolute path. With it set, the test
308
+ suite replays the corpus as one more test, which fails when any case does; without it, or
309
+ with neither `FROSTLAKE_URL` nor `FROSTLAKE_CLASSPATH` to replay against, that test is
310
+ skipped:
311
+
312
+ ```sh
313
+ FL_CORPUS=/path/to/frostlake/engine/src/test/resources/testkit ruby test/test_frostlake.rb
314
+ ```
315
+
316
+ The runner also runs on its own:
317
+
318
+ ```sh
319
+ FL_CORPUS=/path/to/frostlake/engine/src/test/resources/testkit \
320
+ FROSTLAKE_URL=frostlake://127.0.0.1:18082 ruby testkit_runner.rb
321
+ ```
322
+
323
+ Every case recreates `test_db`, so point it at a scratch server — or leave
324
+ `FROSTLAKE_URL` out and it boots one of its own from `FROSTLAKE_CLASSPATH`, in the test
325
+ suite too. `FROSTLAKE_TESTKIT_FILTER=word1,word2` replays only the suites whose name
326
+ contains one of the words.
327
+
328
+ Each case runs on a connection of its own, after the reset the corpus prescribes; a case
329
+ skipped for `ruby` or `http` reports SKIP. The report, `results/testkit-ruby.tsv` unless
330
+ `FROSTLAKE_TESTKIT_REPORT` says otherwise, holds one row per case (`suite`, `test`,
331
+ `status`, `failedStep`, `detail`, `ms`). Beside it `missing-apis-ruby.md` lists the checks
332
+ the protocol cannot express — an expected error's code or SQLSTATE — which are recorded
333
+ rather than failed. The run ends with `testkit [ruby]: <P> passed, <F> failed, <S> skipped`
334
+ and exits 1 when any case failed, or when `FL_CORPUS` holds no suites.
335
+
221
336
  ## Protocol
222
337
 
223
- One `POST /api/execute` per statement with `{ sql, sessionId, autoCommit }`; the server
338
+ One `POST /api/execute` per statement with `{ sql, sessionId, autoCommit }` — plus
339
+ `multiStatementCount` when a call declares one, and nothing at all when it does not; the server
224
340
  issues the `sessionId` on first contact and the driver echoes it back, so session state
225
- (current database/schema, transactions) persists across statements. `GET /api/health`
341
+ (current database/schema, transactions) persists across statements. To an engine that
342
+ reports `newSession` the driver adds `requireSession: true`, and `close` sends
343
+ `DELETE /api/sessions/{id}` (see [Session lifetime](#session-lifetime)). `GET /api/health`
226
344
  backs `Frostlake.connect`'s reachability check.
227
345
 
228
346
  ## License
data/lib/frostlake.rb CHANGED
@@ -32,7 +32,7 @@ rescue LoadError
32
32
  end
33
33
 
34
34
  module Frostlake
35
- VERSION = "0.1.0"
35
+ VERSION = "0.2.0"
36
36
 
37
37
  # Every failure the driver raises is a Frostlake::Error, so one rescue still
38
38
  # catches the lot; the subclasses only say which kind it was.
@@ -41,6 +41,15 @@ module Frostlake
41
41
  # The server could not be reached, or the connection failed mid-statement.
42
42
  class ConnectionError < Error; end
43
43
 
44
+ # The engine no longer holds the connection's session: it expired, was
45
+ # released, or the server restarted. Raised instead of re-running the
46
+ # statement when the lost session held what a fresh one cannot reproduce —
47
+ # an open transaction, or context set up with USE, SET, ALTER SESSION or a
48
+ # temporary object. The statement did not run, and the connection stays
49
+ # usable: its next statement starts a fresh session on the DSN's database
50
+ # and schema.
51
+ class SessionLostError < ConnectionError; end
52
+
44
53
  # The engine rejected a statement. The message is the engine's own.
45
54
  class QueryError < Error; end
46
55
 
@@ -60,14 +69,34 @@ module Frostlake
60
69
  DEFAULT_OPEN_TIMEOUT = 10
61
70
  DEFAULT_READ_TIMEOUT = 300
62
71
 
63
- # The engine reaps a session after 30 minutes idle. Past that we have to
64
- # assume ours is gone, because nothing in a response says so.
72
+ # The engine reaps a session after 30 minutes idle. An engine that predates
73
+ # newSession says nothing when it does, so past that we have to assume ours
74
+ # is gone.
65
75
  DEFAULT_SESSION_IDLE_LIMIT = 1800
66
76
 
77
+ # How long closing may spend releasing the session, in seconds, to connect
78
+ # and again to be answered; a connection timeout that is shorter wins.
79
+ CLOSE_BUDGET = 5
80
+
67
81
  # The engine's binary floating-point types. Every other numeric it reports is
68
82
  # fixed-point and keeps its digits.
69
83
  APPROXIMATE_TYPES = ["FLOAT", "FLOAT4", "FLOAT8", "DOUBLE", "DOUBLE PRECISION", "REAL"].freeze
70
84
 
85
+ # A character of an unquoted identifier or keyword. $ is one, which is why
86
+ # A$$B is a name.
87
+ WORD_CHAR = /[[:alnum:]_$]/.freeze
88
+
89
+ # The words that may sit between CREATE, DROP or ALTER and the kind of
90
+ # object the statement names.
91
+ OBJECT_MODIFIERS = ["OR", "REPLACE", "TRANSIENT", "TEMPORARY", "TEMP", "VOLATILE", "LOCAL", "GLOBAL",
92
+ "SECURE", "IF", "NOT", "EXISTS", "PUBLIC", "PRIVATE", "ICEBERG", "DYNAMIC",
93
+ "HYBRID", "EVENT", "RECURSIVE", "MATERIALIZED", "EXTERNAL"].freeze
94
+
95
+ # The modifiers that make an object temporary: it lives only as long as the
96
+ # session that created it.
97
+ TEMPORARY = ["TEMPORARY", "TEMP", "VOLATILE"].freeze
98
+ private_constant :WORD_CHAR, :OBJECT_MODIFIERS, :TEMPORARY
99
+
71
100
  # Connects, verifies the server is reachable via GET /api/health, and applies
72
101
  # the database and schema from the DSN. Timeouts are in seconds; verify_ssl
73
102
  # and ca_file apply to https DSNs. All four may also be given in the DSN
@@ -130,6 +159,11 @@ module Frostlake
130
159
  end
131
160
 
132
161
  class Connection
162
+ # What round_trip answers when the engine refused the session id as one it
163
+ # no longer holds. Nothing ran.
164
+ SESSION_GONE = :session_gone
165
+ private_constant :SESSION_GONE
166
+
133
167
  def initialize(dsn, open_timeout: nil, read_timeout: nil, verify_ssl: nil, ca_file: nil,
134
168
  session_idle_limit: nil)
135
169
  uri = begin
@@ -188,6 +222,15 @@ module Frostlake
188
222
  # DSN's defaults are no longer the whole truth about this session.
189
223
  @session_touched = false
190
224
  @session_id = nil
225
+ # Whether the engine reports newSession, which arrived together with
226
+ # requireSession and DELETE /api/sessions: nil until the first answer
227
+ # that names a session, which settles it either way.
228
+ @tracks_sessions = nil
229
+ # What the session holds that a fresh one would not: context a statement
230
+ # set up (USE, SET, ALTER SESSION, a temporary object, CREATE or DROP of a
231
+ # database or schema), and an open transaction.
232
+ @dirty = false
233
+ @in_transaction = false
191
234
  @autocommit = true
192
235
  @closed = false
193
236
  @pending_use = []
@@ -198,8 +241,8 @@ module Frostlake
198
241
  raise UsageError, "the DSN path names one database, got #{uri.path.inspect}"
199
242
  end
200
243
  schema = query["schema"]
201
- @pending_use << "USE DATABASE #{self.class.quote_ident(database)}" unless database.empty?
202
- @pending_use << "USE SCHEMA #{self.class.quote_ident(schema)}" if schema
244
+ @pending_use << "USE DATABASE #{self.class.use_ident(database)}" unless database.empty?
245
+ @pending_use << "USE SCHEMA #{self.class.use_ident(schema)}" if schema
203
246
  # Kept so they can be put back if the session is replaced under us.
204
247
  @session_defaults = @pending_use.dup.freeze
205
248
  end
@@ -213,9 +256,7 @@ module Frostlake
213
256
  # on whatever query happens to run first.
214
257
  def use_dsn_defaults
215
258
  check_open
216
- @lock.synchronize do
217
- round_trip(@pending_use.shift) until @pending_use.empty?
218
- end
259
+ @lock.synchronize { apply_pending_use }
219
260
  nil
220
261
  end
221
262
 
@@ -236,31 +277,46 @@ module Frostlake
236
277
  # column name and whose row_count is the affected-row count for DML. A
237
278
  # multi-statement string answers with its first result set — use
238
279
  # execute_all for the rest.
239
- def execute(sql, binds = [])
240
- execute_all(sql, binds).first
280
+ def execute(sql, binds = [], multi_statement_count: nil)
281
+ execute_all(sql, binds, multi_statement_count: multi_statement_count).first
241
282
  end
242
283
 
243
284
  # Executes a statement string and returns every result set it produced, in
244
285
  # order. A single statement gives a one-element array.
245
- def execute_all(sql, binds = [])
286
+ #
287
+ # multi_statement_count says how many statements this call carries, 0 for
288
+ # any number; the engine refuses a call whose count differs, as the account
289
+ # does. It rides on this one request and outranks the session's
290
+ # MULTI_STATEMENT_COUNT for it without changing any session state, so there
291
+ # is nothing to restore and a connection shared between threads is
292
+ # unaffected. Left out, nothing is sent and the session's value decides.
293
+ def execute_all(sql, binds = [], multi_statement_count: nil)
246
294
  check_open
295
+ unless multi_statement_count.nil? ||
296
+ (multi_statement_count.is_a?(Integer) && !multi_statement_count.negative?)
297
+ raise UsageError, "multi_statement_count must be a non-negative Integer, " \
298
+ "got #{multi_statement_count.inspect}"
299
+ end
247
300
  rendered = binds.empty? ? sql : self.class.substitute(sql, binds)
248
301
  # The pending USE statements and the statement itself have to reach the
249
302
  # session as one unit: another thread must not slip a query in between,
250
303
  # and two threads must not both try to shift the same pending entry.
251
304
  @lock.synchronize do
252
- restore_session_defaults
253
- round_trip(@pending_use.shift) until @pending_use.empty?
254
- results = shape_results(round_trip(rendered))
305
+ results = shape_results(perform(rendered, multi_statement_count))
255
306
  @session_touched = true if self.class.selects_session_state?(sql)
256
307
  results
257
308
  end
258
309
  end
259
310
 
311
+ # Opens a transaction: BEGIN, sent with autocommit off. The connection
312
+ # leaves autocommit only once the engine has opened the transaction, so a
313
+ # BEGIN that fails — refused, unreadable, never answered, lost with its
314
+ # session, or never sent because a USE queued ahead of it was refused —
315
+ # leaves every later statement committing as it runs.
260
316
  def begin_transaction
261
317
  @lock.synchronize do
318
+ perform("BEGIN", nil, false)
262
319
  @autocommit = false
263
- execute("BEGIN")
264
320
  end
265
321
  nil
266
322
  end
@@ -297,9 +353,15 @@ module Frostlake
297
353
  raise
298
354
  end
299
355
 
356
+ # Closes the connection and releases its session on the engine with one
357
+ # DELETE /api/sessions/{id}, which also rolls back a transaction left open.
358
+ # The release is best effort, bounded by CLOSE_BUDGET, and never raises;
359
+ # an engine that predates it is sent nothing, and keeps the session until
360
+ # its own idle expiry. Closing again sends nothing.
300
361
  def close
301
362
  @closed = true
302
363
  @lock.synchronize do
364
+ release_session
303
365
  @http.finish if @http.started?
304
366
  rescue IOError
305
367
  # Already gone; closing is still closing.
@@ -314,14 +376,166 @@ module Frostlake
314
376
  raise UsageError, "connection is closed" if @closed
315
377
  end
316
378
 
317
- # The engine reaps a session once it has been idle long enough and then
318
- # quietly builds a fresh one for the id we keep sending, losing the database
319
- # and schema we selected. Nothing in the reply gives it away — the id we
320
- # sent is echoed back either way, and /api/sessions reports only a count —
321
- # so past the limit the only safe reading is that the session is new, and
322
- # the DSN's defaults go back on. Not once the caller has selected something
323
- # themselves: putting our defaults over their choice is its own surprise.
379
+ # One statement on the session, under the lock: the pending USE statements
380
+ # first, with the connection's own autocommit, then the statement with
381
+ # auto_commit, and the session's state tracked from it. Answers the
382
+ # engine's answer.
383
+ def perform(sql, multi_statement_count, auto_commit = @autocommit)
384
+ # Again under the lock: a close that got in first has released the
385
+ # session, and a statement now would start one nobody releases.
386
+ check_open
387
+ restore_session_defaults
388
+ # Each pending USE is one statement of its own, whatever this call declares.
389
+ apply_pending_use
390
+ out = run(sql, multi_statement_count, auto_commit)
391
+ track_session(sql)
392
+ out
393
+ end
394
+
395
+ # Runs one statement on the session, or — when the engine no longer holds
396
+ # it — once more on a fresh one, if nothing the lost session held is lost
397
+ # with it. Sent again, it carries the autocommit it was first sent with.
398
+ def run(sql, multi_statement_count, auto_commit = @autocommit)
399
+ out = round_trip(sql, multi_statement_count, auto_commit)
400
+ return out unless out == SESSION_GONE
401
+
402
+ session_lost
403
+ apply_pending_use
404
+ out = round_trip(sql, multi_statement_count, auto_commit)
405
+ raise SessionLostError, "the engine refused a session it had just started" if out == SESSION_GONE
406
+
407
+ out
408
+ end
409
+
410
+ # Sends the pending USE statements, one request each. A session lost or
411
+ # replaced part-way takes the USEs already sent with it, so the whole of
412
+ # the DSN's scope goes onto the fresh one: once, since an engine that loses
413
+ # the session again within the same few requests is keeping none. And a
414
+ # scope that fails part-way stays pending in full, so no statement runs in
415
+ # a scope nobody chose.
416
+ def apply_pending_use
417
+ restarted = false
418
+ until @pending_use.empty?
419
+ queue = @pending_use.dup
420
+ answer = begin
421
+ round_trip(queue.first)
422
+ rescue Error
423
+ @pending_use.replace(@session_defaults.dup)
424
+ raise
425
+ end
426
+ if answer == SESSION_GONE
427
+ raise SessionLostError, "the engine refused a session it had just started" if restarted
428
+
429
+ restarted = true
430
+ session_lost
431
+ elsif @pending_use == queue || restarted
432
+ @pending_use.replace(queue.drop(1))
433
+ else
434
+ # absorb found the session replaced and queued the scope again.
435
+ restarted = true
436
+ end
437
+ end
438
+ end
439
+
440
+ # The engine no longer holds the session: it expired, was released, or the
441
+ # server restarted, and nothing ran. With a transaction or a moved context
442
+ # gone with it, re-running would put the statement somewhere its author
443
+ # did not intend, so that raises; either way the id is dropped and the
444
+ # DSN's scope queued, so the next statement starts a fresh session there.
445
+ def session_lost
446
+ had_transaction = @in_transaction
447
+ had_context = @dirty
448
+ forget_session
449
+ if had_transaction
450
+ raise SessionLostError,
451
+ "the engine no longer holds this connection's session (it expired, was released, " \
452
+ "or the server restarted), so its open transaction is gone; the statement did not run"
453
+ end
454
+ return unless had_context
455
+
456
+ raise SessionLostError,
457
+ "the engine no longer holds this connection's session (it expired, was released, " \
458
+ "or the server restarted), and the context set up on it (USE, SET, ALTER SESSION or " \
459
+ "a temporary object) went with it, so the statement was not re-run; the next " \
460
+ "statement starts a fresh session on the connection's scope"
461
+ end
462
+
463
+ # Starts over: no session, nothing held on one, and the DSN's scope queued
464
+ # for the next statement. A transaction lost with the session takes the
465
+ # driver's autocommit-off with it.
466
+ def forget_session
467
+ @autocommit = true if @in_transaction
468
+ @session_id = nil
469
+ @dirty = false
470
+ @in_transaction = false
471
+ @pending_use.replace(@session_defaults.dup)
472
+ end
473
+
474
+ # Keeps the connection's picture of its session in step with a statement
475
+ # that succeeded: whether it left context behind that a fresh session would
476
+ # not have, and whether a transaction is open.
477
+ def track_session(sql)
478
+ self.class.statements(sql).each do |statement|
479
+ @dirty = true if self.class.touches_session?(statement)
480
+ case self.class.transaction_effect(statement)
481
+ when :begins then @in_transaction = true
482
+ when :ends then @in_transaction = false
483
+ end
484
+ end
485
+ end
486
+
487
+ # Learns from an answer which session it ran in, and whether the engine
488
+ # tracks sessions: an answer naming one carries newSession, or comes from
489
+ # an engine older than the field, requireSession and DELETE /api/sessions.
490
+ def absorb(out, sent_id)
491
+ id = out["sessionId"]
492
+ return if id.nil?
493
+
494
+ if out.key?("newSession")
495
+ @tracks_sessions = true
496
+ # The engine ran the statement in a fresh session in place of ours, so
497
+ # whatever the old one held is gone, and the DSN's scope goes back on
498
+ # before the next statement.
499
+ forget_session if out["newSession"] == true && !sent_id.nil?
500
+ elsif @tracks_sessions.nil?
501
+ @tracks_sessions = false
502
+ end
503
+ @session_id = id
504
+ end
505
+
506
+ # One DELETE /api/sessions/{id}, bounded and never raising. Releasing the
507
+ # session also rolls back a transaction it left open.
508
+ def release_session
509
+ id = @session_id
510
+ @session_id = nil
511
+ @in_transaction = false
512
+ return if id.nil? || !@tracks_sessions
513
+
514
+ @http.open_timeout = [@http.open_timeout, CLOSE_BUDGET].min
515
+ @http.read_timeout = [@http.read_timeout, CLOSE_BUDGET].min
516
+ @http.write_timeout = [@http.write_timeout, CLOSE_BUDGET].min
517
+ # Net::HTTP re-sends an idempotent request once after a timeout, which
518
+ # would double the budget.
519
+ @http.max_retries = 0
520
+ path = "/api/sessions/#{URI.encode_www_form_component(id).gsub('+', '%20')}"
521
+ @http.request(Net::HTTP::Delete.new(path))
522
+ nil
523
+ rescue StandardError
524
+ # Best effort: the engine's idle expiry releases whatever this did not.
525
+ nil
526
+ end
527
+
528
+ # An engine that predates newSession reaps a session once it has been idle
529
+ # long enough and then quietly builds a fresh one for the id we keep
530
+ # sending, losing the database and schema we selected. Nothing in its reply
531
+ # gives it away — the id we sent is echoed back either way, and
532
+ # /api/sessions reports only a count — so past the limit the only safe
533
+ # reading is that the session is new, and the DSN's defaults go back on.
534
+ # Not once the caller has selected something themselves: putting our
535
+ # defaults over their choice is its own surprise. A later engine refuses a
536
+ # session it no longer holds, and run puts the scope back itself.
324
537
  def restore_session_defaults
538
+ return unless @tracks_sessions == false
325
539
  return if @session_defaults.empty? || @session_touched
326
540
  return if @session_idle_limit.zero? || @last_used_at.nil?
327
541
  return if self.class.monotonic_now - @last_used_at < @session_idle_limit
@@ -343,10 +557,24 @@ module Frostlake
343
557
  @http.ca_file = authority unless authority.nil?
344
558
  end
345
559
 
346
- def round_trip(sql)
560
+ # One POST /api/execute: the parsed answer, or SESSION_GONE when the
561
+ # engine refused the session id as one it no longer holds. auto_commit is
562
+ # the request's autocommit: the connection's own unless a statement asks
563
+ # for another, as BEGIN does.
564
+ def round_trip(sql, multi_statement_count = nil, auto_commit = @autocommit)
347
565
  @lock.synchronize do
348
- payload = { "sql" => sql, "autoCommit" => @autocommit }
349
- payload["sessionId"] = @session_id if @session_id
566
+ sent_id = @session_id
567
+ # Resume this session or refuse, rather than have the engine start a
568
+ # fresh one under the same id where the statement would run without
569
+ # the context set up earlier. Only to an engine known to take the
570
+ # field: an older one might refuse a field it does not know.
571
+ required = !sent_id.nil? && @tracks_sessions == true
572
+ payload = { "sql" => sql, "autoCommit" => auto_commit }
573
+ payload["sessionId"] = sent_id if sent_id
574
+ payload["requireSession"] = true if required
575
+ # Absent unless this call asked for a count: a request without the field
576
+ # is the one the server has always been sent, and the session decides.
577
+ payload["multiStatementCount"] = multi_statement_count unless multi_statement_count.nil?
350
578
  request = Net::HTTP::Post.new("/api/execute", "content-type" => "application/json")
351
579
  request.body = JSON.generate(payload)
352
580
  response = begin
@@ -360,7 +588,12 @@ module Frostlake
360
588
  rescue JSON::ParserError
361
589
  raise ConnectionError, "HTTP #{response.code} with unreadable body"
362
590
  end
363
- @session_id = out["sessionId"] if out["sessionId"]
591
+ # A 404 that names no session is the refusal requireSession asked for.
592
+ if required && response.code.to_s == "404" && !out["success"] && out["sessionId"].nil?
593
+ return SESSION_GONE
594
+ end
595
+
596
+ absorb(out, sent_id)
364
597
  raise QueryError, out["errorMessage"] || "statement failed" unless out["success"]
365
598
 
366
599
  @last_used_at = self.class.monotonic_now
@@ -381,7 +614,10 @@ module Frostlake
381
614
 
382
615
  def shape_result(result_set)
383
616
  columns = (result_set["columns"] || []).map do |c|
384
- { name: c["name"], data_type: c["dataType"], scale: c["scale"] }
617
+ # length is a text column's width in characters and a binary column's in
618
+ # bytes. Every other type sends none, and so does a server that predates
619
+ # the field: it stays nil rather than becoming a width of 0.
620
+ { name: c["name"], data_type: c["dataType"], scale: c["scale"], length: c["length"] }
385
621
  end
386
622
  values = (result_set["rows"] || []).map do |raw|
387
623
  cells = []
@@ -410,6 +646,17 @@ module Frostlake
410
646
  "\"#{text.gsub('"', '""')}\""
411
647
  end
412
648
 
649
+ # A DSN's database or schema, rendered for USE. A plain name means what it
650
+ # means unquoted in SQL — the upper-case object it folds to — so it is
651
+ # folded before it is quoted; anything else is quoted exactly as given.
652
+ # Quoted as given, a lower-case name would ask for a lower-case object,
653
+ # which USE refuses: it resolves names exactly, as live does.
654
+ def use_ident(name)
655
+ text = name.to_s
656
+ text = text.upcase if text.match?(/\A[A-Za-z_][A-Za-z0-9_$]*\z/)
657
+ quote_ident(text)
658
+ end
659
+
413
660
  # Keeps every JSON number exact: the engine serializes fixed-point
414
661
  # numerics from BigDecimal, and Float would round the digits away before
415
662
  # convert ever sees them.
@@ -425,7 +672,9 @@ module Frostlake
425
672
  case (data_type || "").upcase
426
673
  when "DATE"
427
674
  value.is_a?(String) ? Date.parse(value) : value
428
- when "TIMESTAMP", "TIMESTAMP_NTZ", "TIMESTAMP_LTZ", "TIMESTAMP_TZ", "DATETIME"
675
+ when "TIMESTAMP", "TIMESTAMP_NTZ", "DATETIME"
676
+ value.is_a?(String) ? wall_clock(value) : value
677
+ when "TIMESTAMP_LTZ", "TIMESTAMP_TZ"
429
678
  value.is_a?(String) ? Time.parse(value) : value
430
679
  when "BINARY", "VARBINARY"
431
680
  value.is_a?(String) ? decode_hex(value) : value
@@ -434,6 +683,18 @@ module Frostlake
434
683
  end
435
684
  end
436
685
 
686
+ # A TIMESTAMP_NTZ is a wall clock with no zone, and its text carries none.
687
+ # Read in the process's local zone, a wall clock that zone skips would be
688
+ # moved — 01:30 on the night London springs forward would come back as
689
+ # 02:30 — and what a caller read would depend on the machine. UTC skips
690
+ # nothing, so the text is read there: every field comes back as written,
691
+ # on every host, and the instant is the one the engine's epoch arithmetic
692
+ # gives the value. Text with an offset of its own keeps it, since the
693
+ # parser takes the first zone it meets.
694
+ def wall_clock(text)
695
+ Time.parse("#{text} UTC")
696
+ end
697
+
437
698
  # The engine renders binary as hex. Anything else is not ours to
438
699
  # reinterpret: pack("H*") turns "ZZ" into a byte and pads odd-length input
439
700
  # rather than admitting it was handed something else.
@@ -511,6 +772,93 @@ module Frostlake
511
772
  /(\A|[;\n])\s*USE\s/i.match?(sql)
512
773
  end
513
774
 
775
+ # -- session tracking --------------------------------------------------
776
+
777
+ # The request split on its top-level semicolons, blank pieces dropped. A
778
+ # semicolon inside a literal, a quoted identifier, a $$ body or a comment
779
+ # does not split: the same constructs substitute steps over. A scripting
780
+ # block is split along with everything else, which only makes the checks
781
+ # below more willing to flag a request, the safe direction to be wrong in.
782
+ def statements(sql)
783
+ pieces = []
784
+ start = 0
785
+ i = 0
786
+ while i < sql.length
787
+ past = skip_non_code(sql, i)
788
+ if past
789
+ i = past
790
+ elsif sql[i] == ";"
791
+ pieces << sql[start...i]
792
+ start = i + 1
793
+ i += 1
794
+ else
795
+ i += 1
796
+ end
797
+ end
798
+ pieces << sql[start..]
799
+ pieces.reject { |piece| piece.strip.empty? }
800
+ end
801
+
802
+ # Up to limit leading words of a statement, upper-cased, skipping
803
+ # whitespace and comments and stopping at the first thing that is not a
804
+ # word.
805
+ def leading_words(statement, limit)
806
+ words = []
807
+ i = 0
808
+ while words.length < limit && i < statement.length
809
+ ch = statement[i]
810
+ nxt = statement[i + 1]
811
+ if ch.match?(/\s/)
812
+ i += 1
813
+ elsif (ch == "-" && nxt == "-") || (ch == "/" && nxt == "/")
814
+ i = skip_line(statement, i)
815
+ elsif ch == "/" && nxt == "*"
816
+ stop = statement.index("*/", i + 2)
817
+ i = stop.nil? ? statement.length : stop + 2
818
+ elsif ch.match?(WORD_CHAR)
819
+ start = i
820
+ i += 1 while i < statement.length && statement[i].match?(WORD_CHAR)
821
+ words << statement[start...i].upcase
822
+ else
823
+ break
824
+ end
825
+ end
826
+ words
827
+ end
828
+
829
+ # Whether a statement leaves behind state a fresh session would not have:
830
+ # a moved scope (USE, or CREATE or DROP of a DATABASE or SCHEMA), a
831
+ # session variable or setting (SET, UNSET, ALTER SESSION), or a temporary
832
+ # object. CREATE TABLE and its kind leave the session as it was.
833
+ def touches_session?(statement)
834
+ verb, *rest = leading_words(statement, 16)
835
+ case verb
836
+ when "USE", "SET", "UNSET"
837
+ true
838
+ when "ALTER"
839
+ rest.drop_while { |word| OBJECT_MODIFIERS.include?(word) }.first == "SESSION"
840
+ when "CREATE", "DROP"
841
+ modifiers = rest.take_while { |word| OBJECT_MODIFIERS.include?(word) }
842
+ return true if %w[DATABASE SCHEMA].include?(rest[modifiers.length])
843
+
844
+ verb == "CREATE" && modifiers.any? { |word| TEMPORARY.include?(word) }
845
+ else
846
+ false
847
+ end
848
+ end
849
+
850
+ # :begins, :ends or nil — what a statement does to the session's
851
+ # transaction. BEGIN on its own (or with TRANSACTION, WORK or NAME) opens
852
+ # one; BEGIN followed by a statement opens a scripting block instead.
853
+ def transaction_effect(statement)
854
+ words = leading_words(statement, 2)
855
+ return :ends if %w[COMMIT ROLLBACK].include?(words.first)
856
+ return :begins if words == ["START", "TRANSACTION"]
857
+ return nil unless words.first == "BEGIN"
858
+
859
+ words.length == 1 || %w[TRANSACTION WORK NAME].include?(words[1]) ? :begins : nil
860
+ end
861
+
514
862
  # An explicit argument wins over the DSN, which wins over the default.
515
863
  def boolean_for(name, argument, from_dsn, fallback)
516
864
  given = argument.nil? ? from_dsn : argument
@@ -620,6 +968,25 @@ module Frostlake
620
968
 
621
969
  private
622
970
 
971
+ # Index just past the literal, quoted identifier, $$ body or comment
972
+ # starting at i, or nil when i is code; the rules substitute follows.
973
+ def skip_non_code(sql, i)
974
+ ch = sql[i]
975
+ nxt = sql[i + 1]
976
+ if ch == "'"
977
+ skip_string(sql, i)
978
+ elsif ch == '"'
979
+ skip_quoted(sql, i)
980
+ elsif (ch == "-" && nxt == "-") || (ch == "/" && nxt == "/")
981
+ skip_line(sql, i)
982
+ elsif ch == "/" && nxt == "*"
983
+ stop = sql.index("*/", i + 2)
984
+ stop.nil? ? sql.length : stop + 2
985
+ elsif ch == "$" && nxt == "$"
986
+ skip_dollar_quoted(sql, i)
987
+ end
988
+ end
989
+
623
990
  def skip_string(sql, i)
624
991
  j = i + 1
625
992
  while j < sql.length
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: frostlake
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - MLorek