frostlake 0.1.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 +7 -0
- data/LICENSE +201 -0
- data/README.md +230 -0
- data/lib/frostlake.rb +670 -0
- metadata +74 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: bb09cf620d3baab58a71403524ea8a8edc880fc52597196c2e6e10afa4507ad8
|
|
4
|
+
data.tar.gz: 8eefe45f34075e8300cf7964588a0e8b7891da9158c9110c79aa9db5a081280d
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: df21cb9b7ec8a9c37dbe062d705e6f1cdbebc11640c45bcb8db0f2250ef5b9ad203dee933980a802a455efcf2ba1b798ccf1bfee8559205d9371251192e13d49
|
|
7
|
+
data.tar.gz: 16752c3e007ff4c046b5b57762039f0db36ef7e24aa3d9b144b144ebee9cd4f96950d79a1a1f56c6b198e5e643231564fbb1406b5805ce5ea568051acfce3c1b
|
data/LICENSE
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
Apache License
|
|
2
|
+
Version 2.0, January 2004
|
|
3
|
+
http://www.apache.org/licenses/
|
|
4
|
+
|
|
5
|
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
6
|
+
|
|
7
|
+
1. Definitions.
|
|
8
|
+
|
|
9
|
+
"License" shall mean the terms and conditions for use, reproduction,
|
|
10
|
+
and distribution as defined by Sections 1 through 9 of this document.
|
|
11
|
+
|
|
12
|
+
"Licensor" shall mean the copyright owner or entity authorized by
|
|
13
|
+
the copyright owner that is granting the License.
|
|
14
|
+
|
|
15
|
+
"Legal Entity" shall mean the union of the acting entity and all
|
|
16
|
+
other entities that control, are controlled by, or are under common
|
|
17
|
+
control with that entity. For the purposes of this definition,
|
|
18
|
+
"control" means (i) the power, direct or indirect, to cause the
|
|
19
|
+
direction or management of such entity, whether by contract or
|
|
20
|
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
21
|
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
22
|
+
|
|
23
|
+
"You" (or "Your") shall mean an individual or Legal Entity
|
|
24
|
+
exercising permissions granted by this License.
|
|
25
|
+
|
|
26
|
+
"Source" form shall mean the preferred form for making modifications,
|
|
27
|
+
including but not limited to software source code, documentation
|
|
28
|
+
source, and configuration files.
|
|
29
|
+
|
|
30
|
+
"Object" form shall mean any form resulting from mechanical
|
|
31
|
+
transformation or translation of a Source form, including but
|
|
32
|
+
not limited to compiled object code, generated documentation,
|
|
33
|
+
and conversions to other media types.
|
|
34
|
+
|
|
35
|
+
"Work" shall mean the work of authorship, whether in Source or
|
|
36
|
+
Object form, made available under the License, as indicated by a
|
|
37
|
+
copyright notice that is included in or attached to the work
|
|
38
|
+
(an example is provided in the Appendix below).
|
|
39
|
+
|
|
40
|
+
"Derivative Works" shall mean any work, whether in Source or Object
|
|
41
|
+
form, that is based on (or derived from) the Work and for which the
|
|
42
|
+
editorial revisions, annotations, elaborations, or other modifications
|
|
43
|
+
represent, as a whole, an original work of authorship. For the purposes
|
|
44
|
+
of this License, Derivative Works shall not include works that remain
|
|
45
|
+
separable from, or merely link (or bind by name) to the interfaces of,
|
|
46
|
+
the Work and Derivative Works thereof.
|
|
47
|
+
|
|
48
|
+
"Contribution" shall mean any work of authorship, including
|
|
49
|
+
the original version of the Work and any modifications or additions
|
|
50
|
+
to that Work or Derivative Works thereof, that is intentionally
|
|
51
|
+
submitted to Licensor for inclusion in the Work by the copyright owner
|
|
52
|
+
or by an individual or Legal Entity authorized to submit on behalf of
|
|
53
|
+
the copyright owner. For the purposes of this definition, "submitted"
|
|
54
|
+
means any form of electronic, verbal, or written communication sent
|
|
55
|
+
to the Licensor or its representatives, including but not limited to
|
|
56
|
+
communication on electronic mailing lists, source code control systems,
|
|
57
|
+
and issue tracking systems that are managed by, or on behalf of, the
|
|
58
|
+
Licensor for the purpose of discussing and improving the Work, but
|
|
59
|
+
excluding communication that is conspicuously marked or otherwise
|
|
60
|
+
designated in writing by the copyright owner as "Not a Contribution."
|
|
61
|
+
|
|
62
|
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
63
|
+
on behalf of whom a Contribution has been received by Licensor and
|
|
64
|
+
subsequently incorporated within the Work.
|
|
65
|
+
|
|
66
|
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
67
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
68
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
69
|
+
copyright license to reproduce, prepare Derivative Works of,
|
|
70
|
+
publicly display, publicly perform, sublicense, and distribute the
|
|
71
|
+
Work and such Derivative Works in Source or Object form.
|
|
72
|
+
|
|
73
|
+
3. Grant of Patent License. Subject to the terms and conditions of
|
|
74
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
75
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
76
|
+
(except as stated in this section) patent license to make, have made,
|
|
77
|
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
78
|
+
where such license applies only to those patent claims licensable
|
|
79
|
+
by such Contributor that are necessarily infringed by their
|
|
80
|
+
Contribution(s) alone or by combination of their Contribution(s)
|
|
81
|
+
with the Work to which such Contribution(s) was submitted. If You
|
|
82
|
+
institute patent litigation against any entity (including a
|
|
83
|
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
|
84
|
+
or a Contribution incorporated within the Work constitutes direct
|
|
85
|
+
or contributory patent infringement, then any patent licenses
|
|
86
|
+
granted to You under this License for that Work shall terminate
|
|
87
|
+
as of the date such litigation is filed.
|
|
88
|
+
|
|
89
|
+
4. Redistribution. You may reproduce and distribute copies of the
|
|
90
|
+
Work or Derivative Works thereof in any medium, with or without
|
|
91
|
+
modifications, and in Source or Object form, provided that You
|
|
92
|
+
meet the following conditions:
|
|
93
|
+
|
|
94
|
+
(a) You must give any other recipients of the Work or
|
|
95
|
+
Derivative Works a copy of this License; and
|
|
96
|
+
|
|
97
|
+
(b) You must cause any modified files to carry prominent notices
|
|
98
|
+
stating that You changed the files; and
|
|
99
|
+
|
|
100
|
+
(c) You must retain, in the Source form of any Derivative Works
|
|
101
|
+
that You distribute, all copyright, patent, trademark, and
|
|
102
|
+
attribution notices from the Source form of the Work,
|
|
103
|
+
excluding those notices that do not pertain to any part of
|
|
104
|
+
the Derivative Works; and
|
|
105
|
+
|
|
106
|
+
(d) If the Work includes a "NOTICE" text file as part of its
|
|
107
|
+
distribution, then any Derivative Works that You distribute must
|
|
108
|
+
include a readable copy of the attribution notices contained
|
|
109
|
+
within such NOTICE file, excluding those notices that do not
|
|
110
|
+
pertain to any part of the Derivative Works, in at least one
|
|
111
|
+
of the following places: within a NOTICE text file distributed
|
|
112
|
+
as part of the Derivative Works; within the Source form or
|
|
113
|
+
documentation, if provided along with the Derivative Works; or,
|
|
114
|
+
within a display generated by the Derivative Works, if and
|
|
115
|
+
wherever such third-party notices normally appear. The contents
|
|
116
|
+
of the NOTICE file are for informational purposes only and
|
|
117
|
+
do not modify the License. You may add Your own attribution
|
|
118
|
+
notices within Derivative Works that You distribute, alongside
|
|
119
|
+
or as an addendum to the NOTICE text from the Work, provided
|
|
120
|
+
that such additional attribution notices cannot be construed
|
|
121
|
+
as modifying the License.
|
|
122
|
+
|
|
123
|
+
You may add Your own copyright statement to Your modifications and
|
|
124
|
+
may provide additional or different license terms and conditions
|
|
125
|
+
for use, reproduction, or distribution of Your modifications, or
|
|
126
|
+
for any such Derivative Works as a whole, provided Your use,
|
|
127
|
+
reproduction, and distribution of the Work otherwise complies with
|
|
128
|
+
the conditions stated in this License.
|
|
129
|
+
|
|
130
|
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
131
|
+
any Contribution intentionally submitted for inclusion in the Work
|
|
132
|
+
by You to the Licensor shall be under the terms and conditions of
|
|
133
|
+
this License, without any additional terms or conditions.
|
|
134
|
+
Notwithstanding the above, nothing herein shall supersede or modify
|
|
135
|
+
the terms of any separate license agreement you may have executed
|
|
136
|
+
with Licensor regarding such Contributions.
|
|
137
|
+
|
|
138
|
+
6. Trademarks. This License does not grant permission to use the trade
|
|
139
|
+
names, trademarks, service marks, or product names of the Licensor,
|
|
140
|
+
except as required for reasonable and customary use in describing the
|
|
141
|
+
origin of the Work and reproducing the content of the NOTICE file.
|
|
142
|
+
|
|
143
|
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
144
|
+
agreed to in writing, Licensor provides the Work (and each
|
|
145
|
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
146
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
147
|
+
implied, including, without limitation, any warranties or conditions
|
|
148
|
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
149
|
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
150
|
+
appropriateness of using or redistributing the Work and assume any
|
|
151
|
+
risks associated with Your exercise of permissions under this License.
|
|
152
|
+
|
|
153
|
+
8. Limitation of Liability. In no event and under no legal theory,
|
|
154
|
+
whether in tort (including negligence), contract, or otherwise,
|
|
155
|
+
unless required by applicable law (such as deliberate and grossly
|
|
156
|
+
negligent acts) or agreed to in writing, shall any Contributor be
|
|
157
|
+
liable to You for damages, including any direct, indirect, special,
|
|
158
|
+
incidental, or consequential damages of any character arising as a
|
|
159
|
+
result of this License or out of the use or inability to use the
|
|
160
|
+
Work (including but not limited to damages for loss of goodwill,
|
|
161
|
+
work stoppage, computer failure or malfunction, or any and all
|
|
162
|
+
other commercial damages or losses), even if such Contributor
|
|
163
|
+
has been advised of the possibility of such damages.
|
|
164
|
+
|
|
165
|
+
9. Accepting Warranty or Additional Liability. While redistributing
|
|
166
|
+
the Work or Derivative Works thereof, You may choose to offer,
|
|
167
|
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
|
168
|
+
or other liability obligations and/or rights consistent with this
|
|
169
|
+
License. However, in accepting such obligations, You may act only
|
|
170
|
+
on Your own behalf and on Your sole responsibility, not on behalf
|
|
171
|
+
of any other Contributor, and only if You agree to indemnify,
|
|
172
|
+
defend, and hold each Contributor harmless for any liability
|
|
173
|
+
incurred by, or claims asserted against, such Contributor by reason
|
|
174
|
+
of your accepting any such warranty or additional liability.
|
|
175
|
+
|
|
176
|
+
END OF TERMS AND CONDITIONS
|
|
177
|
+
|
|
178
|
+
APPENDIX: How to apply the Apache License to your work.
|
|
179
|
+
|
|
180
|
+
To apply the Apache License to your work, attach the following
|
|
181
|
+
boilerplate notice, with the fields enclosed by brackets "[]"
|
|
182
|
+
replaced with your own identifying information. (Don't include
|
|
183
|
+
the brackets!) The text should be enclosed in the appropriate
|
|
184
|
+
comment syntax for the file format. We also recommend that a
|
|
185
|
+
file or class name and description of purpose be included on the
|
|
186
|
+
same "printed page" as the copyright notice for easier
|
|
187
|
+
identification within third-party archives.
|
|
188
|
+
|
|
189
|
+
Copyright 2026 MLorek
|
|
190
|
+
|
|
191
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
192
|
+
you may not use this file except in compliance with the License.
|
|
193
|
+
You may obtain a copy of the License at
|
|
194
|
+
|
|
195
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
196
|
+
|
|
197
|
+
Unless required by applicable law or agreed to in writing, software
|
|
198
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
199
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
200
|
+
See the License for the specific language governing permissions and
|
|
201
|
+
limitations under the License.
|
data/README.md
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
# frostlake-ruby
|
|
2
|
+
|
|
3
|
+
A zero-dependency Ruby driver for [Frostlake](https://frostlake.dev), speaking the
|
|
4
|
+
engine's HTTP protocol against a running `DatabaseHttpServer`. Ruby ≥ 3.0, stdlib only
|
|
5
|
+
(`net/http`, `json`, `uri`, `date`, `time`, and `bigdecimal` when it is available).
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
gem install frostlake
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Or in a Gemfile:
|
|
14
|
+
|
|
15
|
+
```ruby
|
|
16
|
+
gem "frostlake"
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
The database and schema named in the DSN are applied by `Frostlake.connect`, so a
|
|
20
|
+
name that does not exist is reported there rather than surfacing later on whatever
|
|
21
|
+
query happens to run first. They are quoted before they are sent, so a name that
|
|
22
|
+
could not appear unquoted — one starting with a digit, or a reserved word — is
|
|
23
|
+
passed through intact.
|
|
24
|
+
|
|
25
|
+
## Engine version
|
|
26
|
+
|
|
27
|
+
Requires a Frostlake engine **0.0.7 or newer**. Ask a running server which one it is with
|
|
28
|
+
`SELECT CURRENT_VERSION()` — every release answers it, so the check works against any engine.
|
|
29
|
+
|
|
30
|
+
The driver versions independently of the engine: it speaks the HTTP protocol, not
|
|
31
|
+
the jar, so this is a floor rather than a lockstep pin.
|
|
32
|
+
|
|
33
|
+
One behaviour does depend on the engine: a `TIMESTAMP_TZ` column only reports back the
|
|
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`.
|
|
36
|
+
|
|
37
|
+
## Usage
|
|
38
|
+
|
|
39
|
+
```ruby
|
|
40
|
+
require "frostlake"
|
|
41
|
+
|
|
42
|
+
conn = Frostlake.connect("frostlake://localhost:18082/MY_DB?schema=PUBLIC")
|
|
43
|
+
|
|
44
|
+
conn.execute("CREATE TABLE people (id INTEGER, name VARCHAR)")
|
|
45
|
+
result = conn.execute("INSERT INTO people VALUES (?, ?), (?, ?)", [1, "Ada", 2, "Grace"])
|
|
46
|
+
result.row_count # => 2
|
|
47
|
+
|
|
48
|
+
result = conn.execute("SELECT id, name FROM people WHERE id = ?", [1])
|
|
49
|
+
result.rows # => [{ "ID" => 1, "NAME" => "Ada" }]
|
|
50
|
+
|
|
51
|
+
conn.close
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
`execute(sql, binds = [])` returns a `Frostlake::Result` with `columns`, `rows`
|
|
55
|
+
(hashes keyed by column name; the result is `Enumerable` over them), `values` and
|
|
56
|
+
`row_count` (the affected-row count for DML). A failed statement raises
|
|
57
|
+
`Frostlake::Error` carrying the engine's error message.
|
|
58
|
+
|
|
59
|
+
`values` is what the wire delivered: every cell, positionally, lined up with
|
|
60
|
+
`columns`. `rows` is a view over it keyed by column name, built the first time you
|
|
61
|
+
ask and then kept — so a caller that only reads `values` never pays for the hashes.
|
|
62
|
+
Because it is keyed by name it cannot hold two columns called the same thing: a
|
|
63
|
+
self-join reports `ID` twice and the later one wins, while `values` keeps both.
|
|
64
|
+
|
|
65
|
+
```ruby
|
|
66
|
+
result = conn.execute("SELECT e.id, e.name, m.id, m.name FROM emp e JOIN emp m ON e.manager_id = m.id")
|
|
67
|
+
result.columns.map { |c| c[:name] } # => ["ID", "NAME", "ID", "NAME"]
|
|
68
|
+
result.rows.first # => {"ID" => 1, "NAME" => "Ada"}
|
|
69
|
+
result.values.first # => [2, "Grace", 1, "Ada"]
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### Transactions
|
|
73
|
+
|
|
74
|
+
```ruby
|
|
75
|
+
conn.transaction do
|
|
76
|
+
conn.execute("INSERT INTO acc VALUES (2)")
|
|
77
|
+
end # commits; rolls back if the block raises
|
|
78
|
+
|
|
79
|
+
conn.begin_transaction # or drive it by hand
|
|
80
|
+
conn.execute("...")
|
|
81
|
+
conn.commit # / conn.rollback
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### Bind values
|
|
85
|
+
|
|
86
|
+
Parameters are inlined client-side (`?` placeholders); placeholders inside string
|
|
87
|
+
literals, quoted identifiers, comments and `$$…$$` bodies are left alone. Too few
|
|
88
|
+
bind values raises `Frostlake::UsageError`; surplus ones are ignored, matching
|
|
89
|
+
Frostlake's other drivers.
|
|
90
|
+
|
|
91
|
+
| Ruby value | SQL literal |
|
|
92
|
+
| --- | --- |
|
|
93
|
+
| `nil` | `NULL` |
|
|
94
|
+
| `true` / `false` | `TRUE` / `FALSE` |
|
|
95
|
+
| `Integer` / `Float` / `BigDecimal` | as written |
|
|
96
|
+
| `String` (UTF-8) / `Symbol` | `'…'` (backslashes and quotes escaped) |
|
|
97
|
+
| `String` with `ASCII-8BIT` encoding | `X'hex'` (binary marker) |
|
|
98
|
+
| `Time` / `DateTime` | `'…±hh:mm'::TIMESTAMP_TZ` (a Ruby `Time` always carries an offset) |
|
|
99
|
+
| `Date` | `'…'::DATE` |
|
|
100
|
+
| `Array` | `[…]` (elements formatted recursively) |
|
|
101
|
+
|
|
102
|
+
### Result types
|
|
103
|
+
|
|
104
|
+
Fixed-point `NUMBER` keeps the exact digits the engine sent: `BigDecimal` when the
|
|
105
|
+
column has a scale, `Integer` when it does not (of any size — Ruby integers are
|
|
106
|
+
arbitrary precision). `FLOAT`/`DOUBLE`/`REAL` are genuine binary floats and stay
|
|
107
|
+
`Float`. `BOOLEAN` becomes `true`/`false`, `DATE` a `Date`, `TIMESTAMP*` a `Time`,
|
|
108
|
+
`BINARY` a binary-encoded `String`; `TIME` and semi-structured values keep their
|
|
109
|
+
wire shape as strings.
|
|
110
|
+
|
|
111
|
+
Each entry in `columns` is a hash of `{ name:, data_type:, scale: }`, carrying the
|
|
112
|
+
engine's own type name.
|
|
113
|
+
|
|
114
|
+
A result set arrives as one JSON body and is fully materialised — the driver holds
|
|
115
|
+
every row in memory, and the protocol offers no cursor to page through a large
|
|
116
|
+
`SELECT`. Bound the query rather than expecting the driver to stream it.
|
|
117
|
+
|
|
118
|
+
Exact decimals need `bigdecimal`, which ships with Ruby. If it cannot be loaded —
|
|
119
|
+
a bundler setup on Ruby ≥ 3.4 without it in the Gemfile — those cells fall back to
|
|
120
|
+
`Float` rather than the driver failing to load.
|
|
121
|
+
|
|
122
|
+
### Several statements at once
|
|
123
|
+
|
|
124
|
+
`execute` returns the first result set. `execute_all` returns every one, in order:
|
|
125
|
+
|
|
126
|
+
```ruby
|
|
127
|
+
sets = conn.execute_all("SELECT 1 AS a; SELECT 2 AS b;")
|
|
128
|
+
sets.length # => 2
|
|
129
|
+
sets.last.rows # => [{ "B" => 2 }]
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
If any statement in the string fails the whole call raises and no result sets come
|
|
133
|
+
back — not even for the statements before it. The engine discards their effects
|
|
134
|
+
too: an `INSERT` followed by a failing statement leaves nothing behind, whether the
|
|
135
|
+
failure is a syntax error, a missing table or a division by zero.
|
|
136
|
+
|
|
137
|
+
### Connection options
|
|
138
|
+
|
|
139
|
+
Every option can be given as a keyword or in the DSN query string, the keyword
|
|
140
|
+
winning. Timeouts are in seconds and default to 10s to connect and 300s to wait for
|
|
141
|
+
a statement.
|
|
142
|
+
|
|
143
|
+
```ruby
|
|
144
|
+
Frostlake.connect(dsn, open_timeout: 5, read_timeout: 30)
|
|
145
|
+
Frostlake.connect("frostlake://localhost:18082/DB?open_timeout=5&read_timeout=30")
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
An `https://` DSN verifies the server certificate. Point at your own authority with
|
|
149
|
+
`ca_file`, or turn verification off for a self-signed server:
|
|
150
|
+
|
|
151
|
+
```ruby
|
|
152
|
+
Frostlake.connect("https://localhost:8443/DB", ca_file: "/etc/ssl/my-ca.pem")
|
|
153
|
+
Frostlake.connect("https://localhost:8443/DB?verify_ssl=false")
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
The server authenticates nobody, so a DSN carrying `user:password@` is rejected
|
|
157
|
+
rather than having the credentials quietly dropped.
|
|
158
|
+
|
|
159
|
+
`verify_ssl` accepts `true`/`false`, `yes`/`no` or `1`/`0`. The DSN query string
|
|
160
|
+
accepts `schema`, `open_timeout`, `read_timeout`, `verify_ssl`, `ca_file` and
|
|
161
|
+
`session_idle_limit`, and nothing else: an unknown parameter is refused rather than
|
|
162
|
+
ignored, so a misspelled `schema` cannot quietly leave you in the wrong one. The
|
|
163
|
+
same goes for `verify_ssl` and `ca_file` on a DSN that is not `https` — they are
|
|
164
|
+
refused however they were spelled, since they would do nothing. The path names one
|
|
165
|
+
database, so `frostlake://host/db/extra` is refused too.
|
|
166
|
+
|
|
167
|
+
### Idle sessions
|
|
168
|
+
|
|
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.
|
|
174
|
+
|
|
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.
|
|
180
|
+
|
|
181
|
+
```ruby
|
|
182
|
+
Frostlake.connect(dsn, session_idle_limit: 600) # re-apply after ten idle minutes
|
|
183
|
+
Frostlake.connect("frostlake://host:18082/DB?session_idle_limit=0") # never
|
|
184
|
+
```
|
|
185
|
+
|
|
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
|
+
### Errors
|
|
191
|
+
|
|
192
|
+
Everything the driver raises is a `Frostlake::Error`, so a single rescue still catches
|
|
193
|
+
the lot. The subclass says which kind it was:
|
|
194
|
+
|
|
195
|
+
| Class | Raised when |
|
|
196
|
+
| --- | --- |
|
|
197
|
+
| `Frostlake::ConnectionError` | the server is unreachable, unhealthy, or the request failed |
|
|
198
|
+
| `Frostlake::QueryError` | the engine rejected the statement; the message is the engine's |
|
|
199
|
+
| `Frostlake::UsageError` | the driver was misused: bad DSN, closed connection, unbindable value |
|
|
200
|
+
|
|
201
|
+
### Threads
|
|
202
|
+
|
|
203
|
+
A `Connection` is one socket and one server-side session, so statements on it are
|
|
204
|
+
serialized and it is safe to share. A transaction is session state, though — don't
|
|
205
|
+
drive one from several threads at once. Use a connection per thread if you want
|
|
206
|
+
statements to actually run in parallel.
|
|
207
|
+
|
|
208
|
+
## Running the tests
|
|
209
|
+
|
|
210
|
+
The integration tests boot a real server from the engine's compiled classes:
|
|
211
|
+
|
|
212
|
+
```sh
|
|
213
|
+
export JAVA_HOME=/path/to/jdk17
|
|
214
|
+
export FROSTLAKE_CLASSPATH="/path/to/frostlake/engine/target/classes:<engine deps>"
|
|
215
|
+
rake test # or: ruby test/test_frostlake.rb
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
Without `FROSTLAKE_CLASSPATH` the integration tests skip themselves and only the
|
|
219
|
+
substitution unit tests run.
|
|
220
|
+
|
|
221
|
+
## Protocol
|
|
222
|
+
|
|
223
|
+
One `POST /api/execute` per statement with `{ sql, sessionId, autoCommit }`; the server
|
|
224
|
+
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`
|
|
226
|
+
backs `Frostlake.connect`'s reachability check.
|
|
227
|
+
|
|
228
|
+
## License
|
|
229
|
+
|
|
230
|
+
Apache-2.0 — see [LICENSE](LICENSE).
|
data/lib/frostlake.rb
ADDED
|
@@ -0,0 +1,670 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# A Ruby driver for Frostlake, speaking the engine's HTTP protocol against a
|
|
4
|
+
# running DatabaseHttpServer.
|
|
5
|
+
#
|
|
6
|
+
# require "frostlake"
|
|
7
|
+
# conn = Frostlake.connect("frostlake://localhost:18082/MY_DB?schema=PUBLIC")
|
|
8
|
+
# result = conn.execute("SELECT id, name FROM people WHERE id = ?", [1])
|
|
9
|
+
# result.rows # => [{ "ID" => 1, "NAME" => "Ada" }]
|
|
10
|
+
#
|
|
11
|
+
# Parameters are inlined client-side (the protocol has no server-side binding),
|
|
12
|
+
# with the same rules as Frostlake's other drivers. Rows are hashes keyed by
|
|
13
|
+
# column name; BOOLEAN cells arrive as booleans, DATE as Date, TIMESTAMP* as
|
|
14
|
+
# Time, BINARY as a binary-encoded String. Fixed-point NUMBER keeps its exact
|
|
15
|
+
# digits as BigDecimal (or Integer at scale 0) while FLOAT/DOUBLE/REAL stay
|
|
16
|
+
# Float. A bound Time is sent as TIMESTAMP_TZ, since a Ruby Time always carries
|
|
17
|
+
# a UTC offset.
|
|
18
|
+
|
|
19
|
+
require "date"
|
|
20
|
+
require "json"
|
|
21
|
+
require "monitor"
|
|
22
|
+
require "net/http"
|
|
23
|
+
require "time"
|
|
24
|
+
require "uri"
|
|
25
|
+
|
|
26
|
+
begin
|
|
27
|
+
# Ships with Ruby: a default gem through 3.3, a bundled one from 3.4. Without
|
|
28
|
+
# it, fixed-point cells fall back to Float rather than failing to load.
|
|
29
|
+
require "bigdecimal"
|
|
30
|
+
rescue LoadError
|
|
31
|
+
nil
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
module Frostlake
|
|
35
|
+
VERSION = "0.1.0"
|
|
36
|
+
|
|
37
|
+
# Every failure the driver raises is a Frostlake::Error, so one rescue still
|
|
38
|
+
# catches the lot; the subclasses only say which kind it was.
|
|
39
|
+
class Error < StandardError; end
|
|
40
|
+
|
|
41
|
+
# The server could not be reached, or the connection failed mid-statement.
|
|
42
|
+
class ConnectionError < Error; end
|
|
43
|
+
|
|
44
|
+
# The engine rejected a statement. The message is the engine's own.
|
|
45
|
+
class QueryError < Error; end
|
|
46
|
+
|
|
47
|
+
# The driver was asked for something impossible: a malformed DSN, a closed
|
|
48
|
+
# connection, a bind value with no SQL equivalent.
|
|
49
|
+
class UsageError < Error; end
|
|
50
|
+
|
|
51
|
+
DEFAULT_PORT = 18_082
|
|
52
|
+
|
|
53
|
+
# Everything the DSN query string may carry. Anything else is a typo, and a
|
|
54
|
+
# typo in schema or read_timeout changes behaviour without saying so.
|
|
55
|
+
DSN_PARAMETERS = ["ca_file", "open_timeout", "read_timeout", "schema",
|
|
56
|
+
"session_idle_limit", "verify_ssl"].freeze
|
|
57
|
+
|
|
58
|
+
# Long enough for a slow query, short enough that an unreachable host fails
|
|
59
|
+
# while someone is still watching.
|
|
60
|
+
DEFAULT_OPEN_TIMEOUT = 10
|
|
61
|
+
DEFAULT_READ_TIMEOUT = 300
|
|
62
|
+
|
|
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.
|
|
65
|
+
DEFAULT_SESSION_IDLE_LIMIT = 1800
|
|
66
|
+
|
|
67
|
+
# The engine's binary floating-point types. Every other numeric it reports is
|
|
68
|
+
# fixed-point and keeps its digits.
|
|
69
|
+
APPROXIMATE_TYPES = ["FLOAT", "FLOAT4", "FLOAT8", "DOUBLE", "DOUBLE PRECISION", "REAL"].freeze
|
|
70
|
+
|
|
71
|
+
# Connects, verifies the server is reachable via GET /api/health, and applies
|
|
72
|
+
# the database and schema from the DSN. Timeouts are in seconds; verify_ssl
|
|
73
|
+
# and ca_file apply to https DSNs. All four may also be given in the DSN
|
|
74
|
+
# query string, where an explicit argument outranks them.
|
|
75
|
+
def self.connect(dsn, open_timeout: nil, read_timeout: nil, verify_ssl: nil, ca_file: nil,
|
|
76
|
+
session_idle_limit: nil)
|
|
77
|
+
conn = Connection.new(dsn, open_timeout: open_timeout, read_timeout: read_timeout,
|
|
78
|
+
verify_ssl: verify_ssl, ca_file: ca_file,
|
|
79
|
+
session_idle_limit: session_idle_limit)
|
|
80
|
+
begin
|
|
81
|
+
conn.ping
|
|
82
|
+
conn.use_dsn_defaults
|
|
83
|
+
rescue StandardError
|
|
84
|
+
# Nothing usable came of it, so do not leave a session behind.
|
|
85
|
+
conn.close
|
|
86
|
+
raise
|
|
87
|
+
end
|
|
88
|
+
conn
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
class Result
|
|
92
|
+
include Enumerable
|
|
93
|
+
|
|
94
|
+
# values is every cell, positionally aligned with columns — the shape the
|
|
95
|
+
# wire actually delivered.
|
|
96
|
+
attr_reader :columns, :row_count, :values
|
|
97
|
+
|
|
98
|
+
def initialize(columns, row_count, values = [])
|
|
99
|
+
@columns = columns
|
|
100
|
+
@row_count = row_count
|
|
101
|
+
@values = values
|
|
102
|
+
# A Result outlives the statement that made it and can be handed between
|
|
103
|
+
# threads, so the memoisation below must not be two half-built copies.
|
|
104
|
+
@rows_lock = Mutex.new
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Each row keyed by column name, built on first use and kept. A hash cannot
|
|
108
|
+
# represent two columns called the same thing — a self-join reports ID
|
|
109
|
+
# twice and the later one wins — so values is the lossless view. Building
|
|
110
|
+
# these lazily keeps a caller that only reads values from paying for them.
|
|
111
|
+
def rows
|
|
112
|
+
@rows_lock.synchronize { @rows ||= build_rows }
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def each(&block)
|
|
116
|
+
rows.each(&block)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
private
|
|
120
|
+
|
|
121
|
+
def build_rows
|
|
122
|
+
@values.map do |cells|
|
|
123
|
+
row = {}
|
|
124
|
+
@columns.each_with_index do |column, i|
|
|
125
|
+
row[column[:name]] = cells[i]
|
|
126
|
+
end
|
|
127
|
+
row
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
class Connection
|
|
133
|
+
def initialize(dsn, open_timeout: nil, read_timeout: nil, verify_ssl: nil, ca_file: nil,
|
|
134
|
+
session_idle_limit: nil)
|
|
135
|
+
uri = begin
|
|
136
|
+
URI.parse(dsn)
|
|
137
|
+
rescue URI::InvalidURIError
|
|
138
|
+
raise UsageError, "invalid DSN: #{dsn}"
|
|
139
|
+
end
|
|
140
|
+
scheme = (uri.scheme || "").downcase
|
|
141
|
+
unless %w[frostlake http https].include?(scheme)
|
|
142
|
+
raise UsageError, "DSN must start with frostlake://, http:// or https://"
|
|
143
|
+
end
|
|
144
|
+
raise UsageError, "DSN is missing host[:port]" if uri.host.nil? || uri.host.empty?
|
|
145
|
+
# The server authenticates nobody, so credentials in a DSN would be
|
|
146
|
+
# quietly dropped — and quietly dropping a password is worse than saying so.
|
|
147
|
+
unless uri.userinfo.nil?
|
|
148
|
+
raise UsageError, "the server takes no credentials; remove user:password from the DSN"
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
query = uri.query.nil? ? {} : URI.decode_www_form(uri.query).to_h
|
|
152
|
+
unknown = query.keys - DSN_PARAMETERS
|
|
153
|
+
unless unknown.empty?
|
|
154
|
+
raise UsageError, "unknown DSN parameter: #{unknown.sort.join(', ')} " \
|
|
155
|
+
"(expected #{DSN_PARAMETERS.join(', ')})"
|
|
156
|
+
end
|
|
157
|
+
@host = uri.host
|
|
158
|
+
# URI supplies 80 and 443 for http and https; only the custom scheme needs
|
|
159
|
+
# the engine's own default.
|
|
160
|
+
@port = uri.port || DEFAULT_PORT
|
|
161
|
+
unless (1..65_535).cover?(@port)
|
|
162
|
+
raise UsageError, "DSN port must be between 1 and 65535, got #{@port}"
|
|
163
|
+
end
|
|
164
|
+
# Net::HTTP opens a fresh connection per request, which is deliberate:
|
|
165
|
+
# against DatabaseHttpServer a reused connection costs ~48 ms a statement
|
|
166
|
+
# (a delayed-ACK stall that TCP_NODELAY does not shift) versus ~0.8 ms for
|
|
167
|
+
# a new one. Do not "optimise" this into a kept-alive session.
|
|
168
|
+
@http = Net::HTTP.new(@host, @port)
|
|
169
|
+
if scheme == "https"
|
|
170
|
+
configure_tls(verify_ssl, ca_file, query)
|
|
171
|
+
elsif !verify_ssl.nil? || !ca_file.nil? || query.key?("verify_ssl") || query.key?("ca_file")
|
|
172
|
+
# However they were spelled — keyword or DSN — they would do nothing here.
|
|
173
|
+
raise UsageError, "verify_ssl and ca_file apply to https DSNs only"
|
|
174
|
+
end
|
|
175
|
+
@http.open_timeout = self.class.timeout_for("open_timeout", open_timeout,
|
|
176
|
+
query["open_timeout"], DEFAULT_OPEN_TIMEOUT)
|
|
177
|
+
@http.read_timeout = self.class.timeout_for("read_timeout", read_timeout,
|
|
178
|
+
query["read_timeout"], DEFAULT_READ_TIMEOUT)
|
|
179
|
+
# One socket and one session id per connection: statements serialize so a
|
|
180
|
+
# Connection can be shared between threads without interleaving them. A
|
|
181
|
+
# Monitor rather than a Mutex because execute_all holds the lock across
|
|
182
|
+
# the round trips it makes, each of which takes it again.
|
|
183
|
+
@lock = Monitor.new
|
|
184
|
+
@session_idle_limit = self.class.idle_limit_for(session_idle_limit,
|
|
185
|
+
query["session_idle_limit"])
|
|
186
|
+
@last_used_at = nil
|
|
187
|
+
# Whether the caller has selected anything themselves; if they have, the
|
|
188
|
+
# DSN's defaults are no longer the whole truth about this session.
|
|
189
|
+
@session_touched = false
|
|
190
|
+
@session_id = nil
|
|
191
|
+
@autocommit = true
|
|
192
|
+
@closed = false
|
|
193
|
+
@pending_use = []
|
|
194
|
+
# A trailing slash is fine; a second segment means the caller meant
|
|
195
|
+
# something the DSN cannot express, and "db/extra" is not an identifier.
|
|
196
|
+
database = (uri.path || "").delete_prefix("/").delete_suffix("/")
|
|
197
|
+
if database.include?("/")
|
|
198
|
+
raise UsageError, "the DSN path names one database, got #{uri.path.inspect}"
|
|
199
|
+
end
|
|
200
|
+
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
|
|
203
|
+
# Kept so they can be put back if the session is replaced under us.
|
|
204
|
+
@session_defaults = @pending_use.dup.freeze
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def closed?
|
|
208
|
+
@closed
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# Applies the database and schema named in the DSN. connect calls this, so
|
|
212
|
+
# a name that does not exist is reported there rather than surfacing later
|
|
213
|
+
# on whatever query happens to run first.
|
|
214
|
+
def use_dsn_defaults
|
|
215
|
+
check_open
|
|
216
|
+
@lock.synchronize do
|
|
217
|
+
round_trip(@pending_use.shift) until @pending_use.empty?
|
|
218
|
+
end
|
|
219
|
+
nil
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def ping
|
|
223
|
+
check_open
|
|
224
|
+
@lock.synchronize do
|
|
225
|
+
response = begin
|
|
226
|
+
@http.get("/api/health")
|
|
227
|
+
rescue IOError, SocketError, SystemCallError, Timeout::Error => e
|
|
228
|
+
raise ConnectionError, "cannot reach #{@host}:#{@port}: #{e.message}"
|
|
229
|
+
end
|
|
230
|
+
raise ConnectionError, "server unhealthy: HTTP #{response.code}" unless response.is_a?(Net::HTTPSuccess)
|
|
231
|
+
end
|
|
232
|
+
nil
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# Executes one statement; returns a Result whose rows are hashes keyed by
|
|
236
|
+
# column name and whose row_count is the affected-row count for DML. A
|
|
237
|
+
# multi-statement string answers with its first result set — use
|
|
238
|
+
# execute_all for the rest.
|
|
239
|
+
def execute(sql, binds = [])
|
|
240
|
+
execute_all(sql, binds).first
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# Executes a statement string and returns every result set it produced, in
|
|
244
|
+
# order. A single statement gives a one-element array.
|
|
245
|
+
def execute_all(sql, binds = [])
|
|
246
|
+
check_open
|
|
247
|
+
rendered = binds.empty? ? sql : self.class.substitute(sql, binds)
|
|
248
|
+
# The pending USE statements and the statement itself have to reach the
|
|
249
|
+
# session as one unit: another thread must not slip a query in between,
|
|
250
|
+
# and two threads must not both try to shift the same pending entry.
|
|
251
|
+
@lock.synchronize do
|
|
252
|
+
restore_session_defaults
|
|
253
|
+
round_trip(@pending_use.shift) until @pending_use.empty?
|
|
254
|
+
results = shape_results(round_trip(rendered))
|
|
255
|
+
@session_touched = true if self.class.selects_session_state?(sql)
|
|
256
|
+
results
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def begin_transaction
|
|
261
|
+
@lock.synchronize do
|
|
262
|
+
@autocommit = false
|
|
263
|
+
execute("BEGIN")
|
|
264
|
+
end
|
|
265
|
+
nil
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def commit
|
|
269
|
+
@lock.synchronize do
|
|
270
|
+
execute("COMMIT")
|
|
271
|
+
@autocommit = true
|
|
272
|
+
end
|
|
273
|
+
nil
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def rollback
|
|
277
|
+
@lock.synchronize do
|
|
278
|
+
execute("ROLLBACK")
|
|
279
|
+
@autocommit = true
|
|
280
|
+
end
|
|
281
|
+
nil
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
# Runs the block inside BEGIN ... COMMIT, rolling back on any exception.
|
|
285
|
+
def transaction
|
|
286
|
+
begin_transaction
|
|
287
|
+
result = yield self
|
|
288
|
+
commit
|
|
289
|
+
result
|
|
290
|
+
rescue StandardError
|
|
291
|
+
begin
|
|
292
|
+
rollback
|
|
293
|
+
rescue StandardError
|
|
294
|
+
# A failed rollback must not replace the exception that caused it.
|
|
295
|
+
nil
|
|
296
|
+
end
|
|
297
|
+
raise
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def close
|
|
301
|
+
@closed = true
|
|
302
|
+
@lock.synchronize do
|
|
303
|
+
@http.finish if @http.started?
|
|
304
|
+
rescue IOError
|
|
305
|
+
# Already gone; closing is still closing.
|
|
306
|
+
nil
|
|
307
|
+
end
|
|
308
|
+
nil
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
private
|
|
312
|
+
|
|
313
|
+
def check_open
|
|
314
|
+
raise UsageError, "connection is closed" if @closed
|
|
315
|
+
end
|
|
316
|
+
|
|
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.
|
|
324
|
+
def restore_session_defaults
|
|
325
|
+
return if @session_defaults.empty? || @session_touched
|
|
326
|
+
return if @session_idle_limit.zero? || @last_used_at.nil?
|
|
327
|
+
return if self.class.monotonic_now - @last_used_at < @session_idle_limit
|
|
328
|
+
|
|
329
|
+
@pending_use.concat(@session_defaults)
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
def configure_tls(verify_ssl, ca_file, query)
|
|
333
|
+
begin
|
|
334
|
+
require "openssl"
|
|
335
|
+
rescue LoadError
|
|
336
|
+
raise UsageError, "an https DSN needs openssl, which this Ruby was built without"
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
@http.use_ssl = true
|
|
340
|
+
verify = self.class.boolean_for("verify_ssl", verify_ssl, query["verify_ssl"], true)
|
|
341
|
+
@http.verify_mode = verify ? OpenSSL::SSL::VERIFY_PEER : OpenSSL::SSL::VERIFY_NONE
|
|
342
|
+
authority = ca_file.nil? ? query["ca_file"] : ca_file
|
|
343
|
+
@http.ca_file = authority unless authority.nil?
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def round_trip(sql)
|
|
347
|
+
@lock.synchronize do
|
|
348
|
+
payload = { "sql" => sql, "autoCommit" => @autocommit }
|
|
349
|
+
payload["sessionId"] = @session_id if @session_id
|
|
350
|
+
request = Net::HTTP::Post.new("/api/execute", "content-type" => "application/json")
|
|
351
|
+
request.body = JSON.generate(payload)
|
|
352
|
+
response = begin
|
|
353
|
+
@http.request(request)
|
|
354
|
+
rescue IOError, SocketError, SystemCallError, Timeout::Error => e
|
|
355
|
+
raise ConnectionError, "request failed: #{e.message}"
|
|
356
|
+
end
|
|
357
|
+
# Failed statements answer with a non-2xx status AND the error payload in the body.
|
|
358
|
+
out = begin
|
|
359
|
+
self.class.parse_json(response.body)
|
|
360
|
+
rescue JSON::ParserError
|
|
361
|
+
raise ConnectionError, "HTTP #{response.code} with unreadable body"
|
|
362
|
+
end
|
|
363
|
+
@session_id = out["sessionId"] if out["sessionId"]
|
|
364
|
+
raise QueryError, out["errorMessage"] || "statement failed" unless out["success"]
|
|
365
|
+
|
|
366
|
+
@last_used_at = self.class.monotonic_now
|
|
367
|
+
out
|
|
368
|
+
end
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
def shape_results(out)
|
|
372
|
+
sets = out["resultSets"]
|
|
373
|
+
return [Result.new([], 0, [])] if sets.nil? || sets.empty?
|
|
374
|
+
|
|
375
|
+
results = []
|
|
376
|
+
sets.each do |result_set|
|
|
377
|
+
results << shape_result(result_set)
|
|
378
|
+
end
|
|
379
|
+
results
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
def shape_result(result_set)
|
|
383
|
+
columns = (result_set["columns"] || []).map do |c|
|
|
384
|
+
{ name: c["name"], data_type: c["dataType"], scale: c["scale"] }
|
|
385
|
+
end
|
|
386
|
+
values = (result_set["rows"] || []).map do |raw|
|
|
387
|
+
cells = []
|
|
388
|
+
columns.each_with_index do |column, i|
|
|
389
|
+
cells << self.class.convert(raw[i], column[:data_type], column[:scale])
|
|
390
|
+
end
|
|
391
|
+
cells
|
|
392
|
+
end
|
|
393
|
+
if values.length == 1 && self.class.dml_status?(columns)
|
|
394
|
+
return Result.new([], self.class.dml_row_count(columns, values[0]), [])
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
Result.new(columns, values.length, values)
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
class << self
|
|
401
|
+
# Always quoted. Leaving "unambiguous" names bare let through ones that
|
|
402
|
+
# cannot legally appear unquoted — 1ABC starts with a digit, SELECT is
|
|
403
|
+
# reserved — and quoting costs nothing: "NAME" and NAME name the same
|
|
404
|
+
# object, so only genuinely lower-case names are affected and those had to
|
|
405
|
+
# be quoted anyway.
|
|
406
|
+
def quote_ident(name)
|
|
407
|
+
text = name.to_s
|
|
408
|
+
raise UsageError, "identifier cannot be empty" if text.empty?
|
|
409
|
+
|
|
410
|
+
"\"#{text.gsub('"', '""')}\""
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
# Keeps every JSON number exact: the engine serializes fixed-point
|
|
414
|
+
# numerics from BigDecimal, and Float would round the digits away before
|
|
415
|
+
# convert ever sees them.
|
|
416
|
+
def parse_json(text)
|
|
417
|
+
return JSON.parse(text) unless defined?(BigDecimal)
|
|
418
|
+
|
|
419
|
+
JSON.parse(text, decimal_class: BigDecimal)
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
def convert(value, data_type, scale = 0)
|
|
423
|
+
return nil if value.nil?
|
|
424
|
+
|
|
425
|
+
case (data_type || "").upcase
|
|
426
|
+
when "DATE"
|
|
427
|
+
value.is_a?(String) ? Date.parse(value) : value
|
|
428
|
+
when "TIMESTAMP", "TIMESTAMP_NTZ", "TIMESTAMP_LTZ", "TIMESTAMP_TZ", "DATETIME"
|
|
429
|
+
value.is_a?(String) ? Time.parse(value) : value
|
|
430
|
+
when "BINARY", "VARBINARY"
|
|
431
|
+
value.is_a?(String) ? decode_hex(value) : value
|
|
432
|
+
else
|
|
433
|
+
convert_number(value, (data_type || "").upcase, scale)
|
|
434
|
+
end
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
# The engine renders binary as hex. Anything else is not ours to
|
|
438
|
+
# reinterpret: pack("H*") turns "ZZ" into a byte and pads odd-length input
|
|
439
|
+
# rather than admitting it was handed something else.
|
|
440
|
+
def decode_hex(text)
|
|
441
|
+
return text unless text.match?(/\A(?:[0-9a-fA-F]{2})*\z/)
|
|
442
|
+
|
|
443
|
+
[text].pack("H*")
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
# Fixed-point columns keep the wire's exact digits; FLOAT/DOUBLE/REAL are
|
|
447
|
+
# genuine binary floats and stay that way. Anything non-numeric — strings,
|
|
448
|
+
# booleans, semi-structured JSON text — passes straight through.
|
|
449
|
+
def convert_number(value, data_type, scale)
|
|
450
|
+
return value unless value.is_a?(Numeric)
|
|
451
|
+
return value.to_f if APPROXIMATE_TYPES.include?(data_type)
|
|
452
|
+
return value if value.is_a?(Integer)
|
|
453
|
+
return value unless defined?(BigDecimal) && value.is_a?(BigDecimal)
|
|
454
|
+
# Scale 0 is an integer column; hand back an Integer, but never truncate
|
|
455
|
+
# a value that unexpectedly carries a fraction.
|
|
456
|
+
return value.to_i if scale.to_i.zero? && value.frac.zero?
|
|
457
|
+
|
|
458
|
+
value
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
# Whether a result set is a DML status row rather than data. The protocol
|
|
462
|
+
# carries no statement type, so this goes by shape: DML answers with a
|
|
463
|
+
# single row whose every column is a "number of ..." counter. INSERT and
|
|
464
|
+
# DELETE report one, UPDATE adds "number of multi-joined rows updated",
|
|
465
|
+
# and MERGE reports both an inserted and an updated count.
|
|
466
|
+
def dml_status?(columns)
|
|
467
|
+
return false if columns.empty?
|
|
468
|
+
|
|
469
|
+
columns.each do |column|
|
|
470
|
+
return false unless column[:name].to_s.downcase.start_with?("number of ")
|
|
471
|
+
end
|
|
472
|
+
true
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
# Total rows affected. "number of multi-joined rows updated" is a
|
|
476
|
+
# diagnostic sub-count of rows already counted as updated, so only the
|
|
477
|
+
# "number of rows ..." counters are summed.
|
|
478
|
+
def dml_row_count(columns, cells)
|
|
479
|
+
total = 0
|
|
480
|
+
columns.each_with_index do |column, i|
|
|
481
|
+
next unless column[:name].to_s.downcase.start_with?("number of rows ")
|
|
482
|
+
|
|
483
|
+
value = cells[i]
|
|
484
|
+
total += value.to_i unless value.nil?
|
|
485
|
+
end
|
|
486
|
+
total
|
|
487
|
+
end
|
|
488
|
+
|
|
489
|
+
# A clock that cannot jump backwards over an idle connection.
|
|
490
|
+
def monotonic_now
|
|
491
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
492
|
+
end
|
|
493
|
+
|
|
494
|
+
# Zero switches the idle check off; anything else is seconds.
|
|
495
|
+
def idle_limit_for(argument, from_dsn)
|
|
496
|
+
given = argument.nil? ? from_dsn : argument
|
|
497
|
+
return DEFAULT_SESSION_IDLE_LIMIT if given.nil?
|
|
498
|
+
|
|
499
|
+
seconds = Float(given)
|
|
500
|
+
raise UsageError, "session_idle_limit cannot be negative, got #{given}" if seconds.negative?
|
|
501
|
+
|
|
502
|
+
seconds
|
|
503
|
+
rescue ArgumentError, TypeError
|
|
504
|
+
raise UsageError, "session_idle_limit must be a number of seconds, got #{given.inspect}"
|
|
505
|
+
end
|
|
506
|
+
|
|
507
|
+
# A USE picks the database, schema, warehouse or role for the session.
|
|
508
|
+
# Once the caller has done that themselves, the DSN no longer describes
|
|
509
|
+
# where they are, so the driver stops putting its defaults back.
|
|
510
|
+
def selects_session_state?(sql)
|
|
511
|
+
/(\A|[;\n])\s*USE\s/i.match?(sql)
|
|
512
|
+
end
|
|
513
|
+
|
|
514
|
+
# An explicit argument wins over the DSN, which wins over the default.
|
|
515
|
+
def boolean_for(name, argument, from_dsn, fallback)
|
|
516
|
+
given = argument.nil? ? from_dsn : argument
|
|
517
|
+
return fallback if given.nil?
|
|
518
|
+
return given if given == true || given == false
|
|
519
|
+
|
|
520
|
+
case given.to_s.downcase
|
|
521
|
+
when "true", "1", "yes" then true
|
|
522
|
+
when "false", "0", "no" then false
|
|
523
|
+
else
|
|
524
|
+
raise UsageError, "#{name} must be true or false, got #{given.inspect}"
|
|
525
|
+
end
|
|
526
|
+
end
|
|
527
|
+
|
|
528
|
+
# An explicit argument wins over the DSN, which wins over the default.
|
|
529
|
+
def timeout_for(name, argument, from_dsn, fallback)
|
|
530
|
+
given = argument.nil? ? from_dsn : argument
|
|
531
|
+
return fallback if given.nil?
|
|
532
|
+
|
|
533
|
+
seconds = Float(given)
|
|
534
|
+
raise UsageError, "#{name} must be positive, got #{given}" unless seconds.positive?
|
|
535
|
+
|
|
536
|
+
seconds
|
|
537
|
+
rescue ArgumentError, TypeError
|
|
538
|
+
raise UsageError, "#{name} must be a number of seconds, got #{given.inspect}"
|
|
539
|
+
end
|
|
540
|
+
|
|
541
|
+
# -- client-side parameter binding ------------------------------------
|
|
542
|
+
|
|
543
|
+
def substitute(sql, binds)
|
|
544
|
+
out = +""
|
|
545
|
+
nxt = 0
|
|
546
|
+
i = 0
|
|
547
|
+
while i < sql.length
|
|
548
|
+
ch = sql[i]
|
|
549
|
+
if ch == "'"
|
|
550
|
+
j = skip_string(sql, i)
|
|
551
|
+
out << sql[i...j]
|
|
552
|
+
i = j
|
|
553
|
+
elsif ch == '"'
|
|
554
|
+
j = skip_quoted(sql, i)
|
|
555
|
+
out << sql[i...j]
|
|
556
|
+
i = j
|
|
557
|
+
elsif ch == "-" && sql[i + 1] == "-"
|
|
558
|
+
j = skip_line(sql, i)
|
|
559
|
+
out << sql[i...j]
|
|
560
|
+
i = j
|
|
561
|
+
elsif ch == "/" && sql[i + 1] == "*"
|
|
562
|
+
stop = sql.index("*/", i + 2)
|
|
563
|
+
j = stop.nil? ? sql.length : stop + 2
|
|
564
|
+
out << sql[i...j]
|
|
565
|
+
i = j
|
|
566
|
+
elsif ch == "/" && sql[i + 1] == "/"
|
|
567
|
+
j = skip_line(sql, i)
|
|
568
|
+
out << sql[i...j]
|
|
569
|
+
i = j
|
|
570
|
+
elsif ch == "$" && sql[i + 1] == "$"
|
|
571
|
+
j = skip_dollar_quoted(sql, i)
|
|
572
|
+
out << sql[i...j]
|
|
573
|
+
i = j
|
|
574
|
+
elsif ch == "?"
|
|
575
|
+
raise UsageError, "not enough bind values for placeholders" if nxt >= binds.length
|
|
576
|
+
|
|
577
|
+
out << format_literal(binds[nxt])
|
|
578
|
+
nxt += 1
|
|
579
|
+
i += 1
|
|
580
|
+
else
|
|
581
|
+
out << ch
|
|
582
|
+
i += 1
|
|
583
|
+
end
|
|
584
|
+
end
|
|
585
|
+
out
|
|
586
|
+
end
|
|
587
|
+
|
|
588
|
+
def format_literal(value)
|
|
589
|
+
case value
|
|
590
|
+
when nil then "NULL"
|
|
591
|
+
when true then "TRUE"
|
|
592
|
+
when false then "FALSE"
|
|
593
|
+
when Integer then value.to_s
|
|
594
|
+
when Float
|
|
595
|
+
raise UsageError, "non-finite number #{value}" unless value.finite?
|
|
596
|
+
|
|
597
|
+
value.to_s
|
|
598
|
+
# A Ruby Time always carries a UTC offset, so it maps to TIMESTAMP_TZ;
|
|
599
|
+
# casting to NTZ here silently discarded that offset.
|
|
600
|
+
when Time then "'#{value.strftime('%Y-%m-%dT%H:%M:%S.%6N%:z')}'::TIMESTAMP_TZ"
|
|
601
|
+
when DateTime then format_literal(value.to_time)
|
|
602
|
+
when Date then "'#{value.strftime('%Y-%m-%d')}'::DATE"
|
|
603
|
+
when String
|
|
604
|
+
# A binary-encoded string is the deliberate marker for BINARY data.
|
|
605
|
+
if value.encoding == Encoding::ASCII_8BIT
|
|
606
|
+
"X'#{value.unpack1('H*').upcase}'"
|
|
607
|
+
else
|
|
608
|
+
encode_string(value)
|
|
609
|
+
end
|
|
610
|
+
when Symbol then encode_string(value.to_s)
|
|
611
|
+
when Array then "[#{value.map { |element| format_literal(element) }.join(', ')}]"
|
|
612
|
+
else
|
|
613
|
+
if defined?(BigDecimal) && value.is_a?(BigDecimal)
|
|
614
|
+
value.to_s("F")
|
|
615
|
+
else
|
|
616
|
+
raise UsageError, "unsupported bind type #{value.class}"
|
|
617
|
+
end
|
|
618
|
+
end
|
|
619
|
+
end
|
|
620
|
+
|
|
621
|
+
private
|
|
622
|
+
|
|
623
|
+
def skip_string(sql, i)
|
|
624
|
+
j = i + 1
|
|
625
|
+
while j < sql.length
|
|
626
|
+
if sql[j] == "\\"
|
|
627
|
+
j += 2 # backslash always escapes
|
|
628
|
+
elsif sql[j] == "'"
|
|
629
|
+
return j + 1 unless sql[j + 1] == "'"
|
|
630
|
+
|
|
631
|
+
j += 2
|
|
632
|
+
else
|
|
633
|
+
j += 1
|
|
634
|
+
end
|
|
635
|
+
end
|
|
636
|
+
j
|
|
637
|
+
end
|
|
638
|
+
|
|
639
|
+
def skip_quoted(sql, i)
|
|
640
|
+
j = i + 1
|
|
641
|
+
while j < sql.length
|
|
642
|
+
if sql[j] == '"'
|
|
643
|
+
return j + 1 unless sql[j + 1] == '"'
|
|
644
|
+
|
|
645
|
+
j += 2
|
|
646
|
+
else
|
|
647
|
+
j += 1
|
|
648
|
+
end
|
|
649
|
+
end
|
|
650
|
+
j
|
|
651
|
+
end
|
|
652
|
+
|
|
653
|
+
# Steps over a $$…$$ block. UDF and procedure bodies are written that
|
|
654
|
+
# way, so a ? inside one is part of the body, not a placeholder.
|
|
655
|
+
def skip_dollar_quoted(sql, i)
|
|
656
|
+
stop = sql.index("$$", i + 2)
|
|
657
|
+
stop.nil? ? sql.length : stop + 2
|
|
658
|
+
end
|
|
659
|
+
|
|
660
|
+
def skip_line(sql, i)
|
|
661
|
+
j = sql.index("\n", i)
|
|
662
|
+
j.nil? ? sql.length : j + 1
|
|
663
|
+
end
|
|
664
|
+
|
|
665
|
+
def encode_string(text)
|
|
666
|
+
"'#{text.gsub('\\', '\\\\\\\\').gsub("'", "''")}'"
|
|
667
|
+
end
|
|
668
|
+
end
|
|
669
|
+
end
|
|
670
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: frostlake
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- MLorek
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: minitest
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '5.0'
|
|
19
|
+
type: :development
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '5.0'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: rake
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '13.0'
|
|
33
|
+
type: :development
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '13.0'
|
|
40
|
+
description: Zero-dependency driver for the Frostlake SQL engine, speaking its HTTP
|
|
41
|
+
protocol against a running DatabaseHttpServer.
|
|
42
|
+
executables: []
|
|
43
|
+
extensions: []
|
|
44
|
+
extra_rdoc_files: []
|
|
45
|
+
files:
|
|
46
|
+
- LICENSE
|
|
47
|
+
- README.md
|
|
48
|
+
- lib/frostlake.rb
|
|
49
|
+
homepage: https://frostlake.dev
|
|
50
|
+
licenses:
|
|
51
|
+
- Apache-2.0
|
|
52
|
+
metadata:
|
|
53
|
+
homepage_uri: https://frostlake.dev
|
|
54
|
+
source_code_uri: https://github.com/Frostlake-DB/frostlake-ruby
|
|
55
|
+
bug_tracker_uri: https://github.com/Frostlake-DB/frostlake-ruby/issues
|
|
56
|
+
rubygems_mfa_required: 'true'
|
|
57
|
+
rdoc_options: []
|
|
58
|
+
require_paths:
|
|
59
|
+
- lib
|
|
60
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
61
|
+
requirements:
|
|
62
|
+
- - ">="
|
|
63
|
+
- !ruby/object:Gem::Version
|
|
64
|
+
version: '3.0'
|
|
65
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
66
|
+
requirements:
|
|
67
|
+
- - ">="
|
|
68
|
+
- !ruby/object:Gem::Version
|
|
69
|
+
version: '0'
|
|
70
|
+
requirements: []
|
|
71
|
+
rubygems_version: 3.6.7
|
|
72
|
+
specification_version: 4
|
|
73
|
+
summary: Ruby driver for Frostlake over its HTTP protocol
|
|
74
|
+
test_files: []
|