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.
Files changed (5) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +201 -0
  3. data/README.md +230 -0
  4. data/lib/frostlake.rb +670 -0
  5. 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: []