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.
- checksums.yaml +4 -4
- data/README.md +139 -21
- data/lib/frostlake.rb +395 -28
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3cf1e48b23f2ced0c5ddea38ec4df19cb621c1ea10138f0a608c47f7781feebc
|
|
4
|
+
data.tar.gz: 1fe5fbed7c4ac104666ebe75a48ed8d4b27b6f739177ea4989d69cc89ed8c1dd
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
-
|
|
112
|
-
|
|
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
|
-
###
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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 }
|
|
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.
|
|
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.
|
|
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.
|
|
64
|
-
#
|
|
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.
|
|
202
|
-
@pending_use << "USE SCHEMA #{self.class.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
318
|
-
#
|
|
319
|
-
# and
|
|
320
|
-
#
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
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
|
-
|
|
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
|
-
|
|
349
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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", "
|
|
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
|