skaidb 1.0.3
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/CHANGELOG.md +122 -0
- data/LICENSE +557 -0
- data/README.md +484 -0
- data/docs/api.md +146 -0
- data/docs/getting-started.md +127 -0
- data/docs/pooling.md +47 -0
- data/docs/streaming.md +69 -0
- data/docs/tls.md +59 -0
- data/docs/types.md +72 -0
- data/lib/skaidb.rb +1334 -0
- metadata +78 -0
data/README.md
ADDED
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
# skaidb — Ruby driver
|
|
2
|
+
|
|
3
|
+
[](https://github.com/porcupin26/skaidb-ruby/actions/workflows/ci.yml)
|
|
4
|
+
[](https://github.com/porcupin26/skaidb-ruby/releases/latest)
|
|
5
|
+
[](https://rubygems.org/gems/skaidb)
|
|
6
|
+
[](https://github.com/porcupin26/skaidb-ruby/blob/main/LICENSE)
|
|
7
|
+
|
|
8
|
+
The official Ruby driver for [skaidb](https://skaidb.org). The API is modelled
|
|
9
|
+
on the [ruby-pg](https://rubygems.org/gems/pg) gem: `Skaidb.connect` returns a
|
|
10
|
+
connection, `exec` / `exec_params` run statements with `$1`-style parameters,
|
|
11
|
+
and the result behaves like `PG::Result`. **Pure standard library** (`socket`,
|
|
12
|
+
`openssl`, `securerandom`, `bigdecimal`) — no third-party code, one file,
|
|
13
|
+
Ruby 2.7 through 3.4. It speaks skaidb's binary protocol directly:
|
|
14
|
+
SCRAM-SHA-256 authentication, server-side prepared statements with typed
|
|
15
|
+
parameters, one-round-trip batches, streamed result sets, multi-seed failover,
|
|
16
|
+
transparent reconnect, TLS and connection pooling.
|
|
17
|
+
|
|
18
|
+
- Repository: <https://github.com/porcupin26/skaidb-ruby>
|
|
19
|
+
- RubyGems: <https://rubygems.org/gems/skaidb>
|
|
20
|
+
- Full reference: [`docs/`](https://github.com/porcupin26/skaidb-ruby/tree/main/docs) —
|
|
21
|
+
[getting started](https://github.com/porcupin26/skaidb-ruby/blob/main/docs/getting-started.md),
|
|
22
|
+
[API reference](https://github.com/porcupin26/skaidb-ruby/blob/main/docs/api.md),
|
|
23
|
+
[types](https://github.com/porcupin26/skaidb-ruby/blob/main/docs/types.md),
|
|
24
|
+
[TLS](https://github.com/porcupin26/skaidb-ruby/blob/main/docs/tls.md),
|
|
25
|
+
[streaming](https://github.com/porcupin26/skaidb-ruby/blob/main/docs/streaming.md),
|
|
26
|
+
[pooling](https://github.com/porcupin26/skaidb-ruby/blob/main/docs/pooling.md),
|
|
27
|
+
[changelog](https://github.com/porcupin26/skaidb-ruby/blob/main/CHANGELOG.md)
|
|
28
|
+
- Server documentation: <https://skaidb.org/docs/>
|
|
29
|
+
- Wire protocol the driver speaks: <https://skaidb.org/docs/PROTOCOL.html>
|
|
30
|
+
|
|
31
|
+
## Install
|
|
32
|
+
|
|
33
|
+
The gem is published on RubyGems.org as
|
|
34
|
+
[`skaidb`](https://rubygems.org/gems/skaidb) and is loaded with
|
|
35
|
+
`require "skaidb"`:
|
|
36
|
+
|
|
37
|
+
```sh
|
|
38
|
+
gem install skaidb
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
With Bundler:
|
|
42
|
+
|
|
43
|
+
```ruby
|
|
44
|
+
# Gemfile
|
|
45
|
+
gem "skaidb", "~> 1.0"
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Bundler resolves the gem's one dependency, `bigdecimal` (part of Ruby's
|
|
49
|
+
standard library, declared because it is a bundled rather than default gem
|
|
50
|
+
from Ruby 3.4), from RubyGems.org and compiles it, even on a Ruby that ships
|
|
51
|
+
it; that needs the Ruby headers and a C toolchain (`ruby-dev` and
|
|
52
|
+
`build-essential` on Debian/Ubuntu, `ruby-devel` and `gcc` on Fedora/RHEL,
|
|
53
|
+
Xcode command-line tools on macOS). `gem install skaidb` and vendoring reuse
|
|
54
|
+
the `bigdecimal` already installed.
|
|
55
|
+
|
|
56
|
+
Or vendor the single file: copy
|
|
57
|
+
[`lib/skaidb.rb`](https://github.com/porcupin26/skaidb-ruby/blob/v1.0.3/lib/skaidb.rb)
|
|
58
|
+
into your project and `require_relative` it. It has no dependencies beyond
|
|
59
|
+
Ruby's standard library.
|
|
60
|
+
|
|
61
|
+
Every release is also attached as a `.gem` to its
|
|
62
|
+
[GitHub release](https://github.com/porcupin26/skaidb-ruby/releases) for
|
|
63
|
+
installs without registry access: `gem install ./skaidb-1.0.3.gem`.
|
|
64
|
+
|
|
65
|
+
## Quick start
|
|
66
|
+
|
|
67
|
+
```ruby
|
|
68
|
+
require "skaidb"
|
|
69
|
+
|
|
70
|
+
conn = Skaidb.connect(host: "localhost", port: 7000,
|
|
71
|
+
user: "skaidb", password: "secret", database: "app")
|
|
72
|
+
|
|
73
|
+
conn.exec("CREATE TABLE users (PRIMARY KEY (id))")
|
|
74
|
+
conn.exec_params("INSERT INTO users (id, name, tags) VALUES ($1, $2, $3)",
|
|
75
|
+
[1, "Ada", ["math", "eng"]])
|
|
76
|
+
|
|
77
|
+
res = conn.exec_params("SELECT id, name, tags FROM users WHERE id = $1", [1])
|
|
78
|
+
res.each { |row| puts row["name"] } # Ada
|
|
79
|
+
p res.fields # ["id", "name", "tags"]
|
|
80
|
+
p res.rows # [[1, "Ada", ["math", "eng"]]]
|
|
81
|
+
|
|
82
|
+
conn.close
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
With a block the connection is closed for you:
|
|
86
|
+
|
|
87
|
+
```ruby
|
|
88
|
+
Skaidb.connect(host: "localhost", user: "skaidb", password: "secret") do |conn|
|
|
89
|
+
conn.exec("SELECT id, name FROM users ORDER BY id").each { |row| p row }
|
|
90
|
+
end
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Table of contents
|
|
94
|
+
|
|
95
|
+
- [Connecting](#connecting) — host/port, seeds and failover, `database:`,
|
|
96
|
+
timeout, TLS, consistency, health
|
|
97
|
+
- [Statements and parameters](#statements-and-parameters) — `$1` binding,
|
|
98
|
+
prepared statements, `exec_batch`
|
|
99
|
+
- [Results](#results)
|
|
100
|
+
- [Streaming large results](#streaming-large-results)
|
|
101
|
+
- [Connection pool](#connection-pool)
|
|
102
|
+
- [Streams (`CREATE STREAM`)](#streams-create-stream) — `subscribe`
|
|
103
|
+
- [Type mapping](#type-mapping)
|
|
104
|
+
- [Errors](#errors)
|
|
105
|
+
- [Transactions](#transactions)
|
|
106
|
+
- [Thread safety](#thread-safety)
|
|
107
|
+
- [Client identification and version](#client-identification-and-version)
|
|
108
|
+
- [Compatibility](#compatibility)
|
|
109
|
+
|
|
110
|
+
## Connecting
|
|
111
|
+
|
|
112
|
+
```ruby
|
|
113
|
+
Skaidb.connect(
|
|
114
|
+
host: "localhost", port: 7000,
|
|
115
|
+
user: "anonymous", password: "",
|
|
116
|
+
consistency: :quorum, # :one | :quorum | :all (also "ONE"… or 0/1/2)
|
|
117
|
+
timeout: 10.0, # TCP connect timeout in seconds; nil = none
|
|
118
|
+
database: nil, # run USE <database> as part of connecting
|
|
119
|
+
seeds: nil, # ["db1", "db2:7000", …] tried in random order
|
|
120
|
+
tls: false, tls_ca: nil, tls_insecure: false, tls_server_name: "skaidb"
|
|
121
|
+
) # => Skaidb::Connection (or the block's value when a block is given)
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
`connect` dials, runs the SCRAM-SHA-256 handshake (with mutual
|
|
125
|
+
authentication — the server proves it knows your password too), sends a
|
|
126
|
+
best-effort `Hello` naming the driver and its version, and runs `USE` if a
|
|
127
|
+
`database` was given. Omit `user`/`password` for a server with authentication
|
|
128
|
+
disabled.
|
|
129
|
+
|
|
130
|
+
### Seeds and failover
|
|
131
|
+
|
|
132
|
+
skaidb is leaderless: every node accepts every read and write. Pass the
|
|
133
|
+
cluster's addresses as `seeds`; they are tried in **randomized order** until
|
|
134
|
+
one connects *and* authenticates, which also spreads a fleet of clients across
|
|
135
|
+
the nodes.
|
|
136
|
+
|
|
137
|
+
```ruby
|
|
138
|
+
conn = Skaidb.connect(seeds: ["db1", "db2:7000", "db3"], database: "app")
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Each seed is `"host"` or `"host:port"` (the port after the last colon wins; a
|
|
142
|
+
seed without a port uses `port:`). When a connection's transport dies, the
|
|
143
|
+
**next statement re-dials** across the same seeds, re-authenticates, re-sends
|
|
144
|
+
`Hello`, re-runs `USE`, and drops the connection's prepared statements — a
|
|
145
|
+
recovered connection is indistinguishable from a fresh one. The statement that
|
|
146
|
+
discovered the loss raises `Skaidb::ConnectionError`; the one after it goes
|
|
147
|
+
through.
|
|
148
|
+
|
|
149
|
+
### Timeout
|
|
150
|
+
|
|
151
|
+
`timeout` bounds the TCP dial only. Reads have no deadline: a peer that is
|
|
152
|
+
alive but silent blocks the caller, as in ruby-pg. `close` deliberately does
|
|
153
|
+
not take the connection's lock, so another thread can break a stuck read by
|
|
154
|
+
closing the connection.
|
|
155
|
+
|
|
156
|
+
### TLS
|
|
157
|
+
|
|
158
|
+
```ruby
|
|
159
|
+
conn = Skaidb.connect(host: "db1", tls: true) # system trust store
|
|
160
|
+
conn = Skaidb.connect(host: "db1", tls_ca: "/etc/skaidb/ca.crt") # the cluster CA
|
|
161
|
+
conn = Skaidb.connect(host: "db1", tls_insecure: true) # dev only: no verification
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
Any of the three enables TLS. The server name sent as SNI and checked against
|
|
165
|
+
the certificate is `tls_server_name` (default `"skaidb"`, the SAN skaidb's own
|
|
166
|
+
certificates carry). SCRAM runs inside the TLS session. The driver does not
|
|
167
|
+
present a client certificate. Details: [docs/tls.md](https://github.com/porcupin26/skaidb-ruby/blob/main/docs/tls.md).
|
|
168
|
+
|
|
169
|
+
### Consistency
|
|
170
|
+
|
|
171
|
+
skaidb has tunable consistency; the driver's default is `:quorum`. Set it per
|
|
172
|
+
connection and override it per statement:
|
|
173
|
+
|
|
174
|
+
```ruby
|
|
175
|
+
conn = Skaidb.connect(host: "db1", consistency: :one)
|
|
176
|
+
conn.consistency = :all # subsequent statements
|
|
177
|
+
conn.exec_params("SELECT …", [], consistency: :one) # this statement only
|
|
178
|
+
conn.exec_batch("INSERT …", rows, consistency: :quorum)
|
|
179
|
+
conn.stream("SELECT …", consistency: :one) { |row| … }
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
`Skaidb::Consistency::ONE / QUORUM / ALL` are the integers `0 / 1 / 2`;
|
|
183
|
+
`Skaidb::Consistency.resolve` accepts a Symbol, a String (any case) or the
|
|
184
|
+
integer and raises `Skaidb::Error` otherwise. DDL is always run at quorum by
|
|
185
|
+
the server regardless of this setting.
|
|
186
|
+
|
|
187
|
+
### Health
|
|
188
|
+
|
|
189
|
+
- `conn.usable?` — no round-trip. `true` unless the connection is closed, was
|
|
190
|
+
left out of sync by a transport error or an undrainable abandoned stream, or
|
|
191
|
+
is currently mid-stream.
|
|
192
|
+
- `conn.closed` / `conn.finished?` — `true` after `close`.
|
|
193
|
+
|
|
194
|
+
## Statements and parameters
|
|
195
|
+
|
|
196
|
+
Placeholders are pg-style `$1`, `$2`, …; pass parameters as an Array:
|
|
197
|
+
|
|
198
|
+
```ruby
|
|
199
|
+
conn.exec_params("SELECT * FROM users WHERE name = $1 AND age > $2", ["O'Brien", 30])
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
`exec(sql)` (alias `query`) runs a statement with no parameters. The same
|
|
203
|
+
`$N` may appear more than once. A bare `?` in a statement that has parameters
|
|
204
|
+
is rejected up front with a message saying the driver's syntax is `$1, $2, …`
|
|
205
|
+
(the server would otherwise fail it late with a confusing arity error).
|
|
206
|
+
|
|
207
|
+
### How binding works
|
|
208
|
+
|
|
209
|
+
A parameterized statement is **prepared on the server** and its values are
|
|
210
|
+
sent as typed values over the binary protocol. Nothing is interpolated into
|
|
211
|
+
SQL text, so `"O'Brien"` needs no escaping, and a parameter can be an Array
|
|
212
|
+
(→ Array) or a Hash (→ Document), which have no SQL literal form:
|
|
213
|
+
|
|
214
|
+
```ruby
|
|
215
|
+
conn.exec_params("INSERT INTO docs (id, meta) VALUES ($1, $2)",
|
|
216
|
+
[7, { "city" => "London", "tags" => ["a", "b"] }])
|
|
217
|
+
conn.exec_params("SELECT id FROM users WHERE id IN ($1)", [[1, 2, 3]]) # set membership
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
Prepared statements are cached per connection (up to 240 entries, under the
|
|
221
|
+
server's 256-per-connection limit) and reused. The cache is dropped when the
|
|
222
|
+
connection re-dials.
|
|
223
|
+
|
|
224
|
+
Statement kinds the server refuses to prepare (DDL and session control such
|
|
225
|
+
as `USE`) fall back to **client-side text binding** for scalar parameters: a
|
|
226
|
+
String is quoted with `''` escaping, a `Time` becomes epoch milliseconds, a
|
|
227
|
+
binary String becomes a hex literal, and Arrays/Hashes raise `QueryError` on
|
|
228
|
+
this path — with the server's reason for refusing the prepare appended, since
|
|
229
|
+
that is usually the real mistake. Mismatched placeholder/parameter counts
|
|
230
|
+
raise `QueryError` on either path.
|
|
231
|
+
|
|
232
|
+
### `exec_batch` — bulk writes in one round-trip
|
|
233
|
+
|
|
234
|
+
```ruby
|
|
235
|
+
n = conn.exec_batch("INSERT INTO t (id, v) VALUES ($1, $2)", [[1, "a"], [2, "b"], [3, "c"]])
|
|
236
|
+
n # => 3, total rows affected
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
The statement is prepared once and **every parameter row ships in a single
|
|
240
|
+
frame** (`ExecuteBatch`). Each row autocommits on its own; on a failure the
|
|
241
|
+
`QueryError` names the row index and earlier rows stay applied, so the
|
|
242
|
+
statement should be idempotent. A statement the server cannot prepare raises
|
|
243
|
+
`QueryError` rather than falling back. An empty row list returns 0 without a
|
|
244
|
+
round-trip.
|
|
245
|
+
|
|
246
|
+
## Results
|
|
247
|
+
|
|
248
|
+
`exec`, `query`, `exec_params` and `exec_prepared` return a `Skaidb::Result`,
|
|
249
|
+
shaped like `PG::Result`:
|
|
250
|
+
|
|
251
|
+
- `fields` (alias `columns`): column names in order; `nfields` / `num_fields`.
|
|
252
|
+
- `rows`: rows as Arrays of values; `ntuples` / `num_tuples`.
|
|
253
|
+
- `each` / `Enumerable` / `values` / `to_a`: rows as Hashes keyed by column
|
|
254
|
+
name (String keys); `res[i]` is row `i` as a Hash; `getvalue(row, col)` one
|
|
255
|
+
cell by index or column name.
|
|
256
|
+
- `cmd_tuples`: rows affected by `INSERT`/`UPDATE`/`DELETE`; `0` for a
|
|
257
|
+
`SELECT` and for DDL.
|
|
258
|
+
- `result_sets`: for a `CALL` whose procedure `EMIT`s several result sets,
|
|
259
|
+
every set in order (each a `Result`); the last set is the result's own
|
|
260
|
+
`rows`/`fields`. Empty for an ordinary reply.
|
|
261
|
+
|
|
262
|
+
A result holds all its rows in memory; for results that do not fit, stream.
|
|
263
|
+
|
|
264
|
+
## Streaming large results
|
|
265
|
+
|
|
266
|
+
`conn.stream(sql, consistency: nil)` runs the statement over the streaming
|
|
267
|
+
opcode and yields one row Hash at a time, holding **one chunk** in memory:
|
|
268
|
+
|
|
269
|
+
```ruby
|
|
270
|
+
conn.stream("SELECT id, pad FROM big ORDER BY id") do |row|
|
|
271
|
+
break if enough?(row) # leaving early drains the tail
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
first = conn.stream("SELECT id FROM big ORDER BY id").first(10) # Enumerator form
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
`stream` takes SQL text only (no parameters). A non-row statement returns
|
|
278
|
+
`nil` without yielding.
|
|
279
|
+
|
|
280
|
+
**The abandon/drain rule.** The connection is busy for the whole stream:
|
|
281
|
+
a statement from another thread waits for it. Leaving the block early —
|
|
282
|
+
`break`, `return`, an exception, or any Enumerable method on the Enumerator
|
|
283
|
+
form — drains the frames the server is still sending so the socket sits at a
|
|
284
|
+
request boundary again. Draining transfers the rest of the result: if you only
|
|
285
|
+
want the first rows, say so with `LIMIT`. External iteration (`next`/`peek`)
|
|
286
|
+
on the Enumerator form cannot unwind and leaves the connection marked busy;
|
|
287
|
+
iterate with a block or `each`. A frame that makes no sense mid-stream, or a
|
|
288
|
+
dead socket, marks the connection broken instead; `usable?` turns `false`
|
|
289
|
+
and the next statement re-dials. Details: [docs/streaming.md](https://github.com/porcupin26/skaidb-ruby/blob/main/docs/streaming.md).
|
|
290
|
+
|
|
291
|
+
## Connection pool
|
|
292
|
+
|
|
293
|
+
```ruby
|
|
294
|
+
pool = Skaidb::Pool.new(seeds: ["db1", "db2", "db3"], database: "app", maxsize: 8)
|
|
295
|
+
|
|
296
|
+
pool.with { |conn| conn.exec_params("SELECT … WHERE id IN ($1)", [[1, 2, 3]]) }
|
|
297
|
+
|
|
298
|
+
conn = pool.checkout; …; pool.checkin(conn) # the explicit form
|
|
299
|
+
pool.close # closes idle connections
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
`Skaidb::Pool` is thread-safe and accepts every `Skaidb.connect` keyword.
|
|
303
|
+
`maxsize` bounds the number of **idle** connections retained; checkout never
|
|
304
|
+
blocks — when no idle connection is available a new one is dialed, and a
|
|
305
|
+
returned connection beyond `maxsize` is closed. Connections are validated with
|
|
306
|
+
`usable?` on checkout and check-in, so one broken by a transport error or an
|
|
307
|
+
undrained stream is closed and replaced. Details: [docs/pooling.md](https://github.com/porcupin26/skaidb-ruby/blob/main/docs/pooling.md).
|
|
308
|
+
|
|
309
|
+
## Streams (`CREATE STREAM`)
|
|
310
|
+
|
|
311
|
+
`conn.subscribe(name, after: nil, poll: 0.5)` yields a stream's events forever
|
|
312
|
+
as Hashes with `"id"`, `"op"`, `"k"`, `"ts"`, `"doc"`. It polls the stream's
|
|
313
|
+
log with a keyset cursor (500 events per page, sleeping `poll` seconds when
|
|
314
|
+
caught up), so it needs no MQTT client. `id` is an opaque String that sorts in
|
|
315
|
+
log order — persist the last one you handled and pass it as `after:` to resume
|
|
316
|
+
exactly there.
|
|
317
|
+
|
|
318
|
+
```ruby
|
|
319
|
+
conn.subscribe("big_orders", after: checkpoint) do |ev|
|
|
320
|
+
handle(ev["doc"])
|
|
321
|
+
checkpoint = ev["id"]
|
|
322
|
+
end
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
For push delivery, subscribe to `$stream/<db>/<name>` with any MQTT client
|
|
326
|
+
instead; the events are identical.
|
|
327
|
+
|
|
328
|
+
## Type mapping
|
|
329
|
+
|
|
330
|
+
| skaidb value | Ruby → bind | Ruby ← result |
|
|
331
|
+
|--------------|-----------------------------------------------|-------------------------------------|
|
|
332
|
+
| Null | `nil` | `nil` |
|
|
333
|
+
| Bool | `true` / `false` | `true` / `false` |
|
|
334
|
+
| Int | `Integer` (signed 64-bit) | `Integer` |
|
|
335
|
+
| Float | `Float` (finite only) | `Float` |
|
|
336
|
+
| Decimal | `BigDecimal` (finite, 128-bit mantissa) | `BigDecimal` |
|
|
337
|
+
| String | `String` (any text encoding), `Symbol` | `String` (UTF-8) |
|
|
338
|
+
| Bytes | `String` with `Encoding::BINARY` (`"…".b`) | `String` with `Encoding::BINARY` |
|
|
339
|
+
| Uuid | `Skaidb::Uuid` | `String`, canonical lowercase |
|
|
340
|
+
| Timestamp | `Time` (millisecond precision) | `Time` (UTC) |
|
|
341
|
+
| Array | `Array` | `Array` |
|
|
342
|
+
| Document | `Hash` (keys become Strings) | `Hash`, keys in server order |
|
|
343
|
+
|
|
344
|
+
Worth knowing:
|
|
345
|
+
|
|
346
|
+
- A `String` binds as Bytes when its encoding is `ASCII-8BIT`/`BINARY` and as
|
|
347
|
+
String otherwise, so read a file with `File.binread` or call `.b` to store
|
|
348
|
+
raw bytes, and keep text in UTF-8.
|
|
349
|
+
- A UUID result is a String, so `"…" == row["id"]` works; to *bind* one with
|
|
350
|
+
the Uuid type wrap it: `Skaidb::Uuid.new("6ba7b810-…")`, `Skaidb::Uuid.random`,
|
|
351
|
+
`Skaidb::Uuid.from_bytes(raw16)`. A `Uuid` compares equal to a String in the
|
|
352
|
+
same canonical form.
|
|
353
|
+
- `Time` keeps its instant whatever its zone; sub-millisecond precision is
|
|
354
|
+
truncated. Results are UTC.
|
|
355
|
+
- A Document result is a `Hash` whose keys come back in the order the server
|
|
356
|
+
sends them, which is not the order they were bound in: the server stores a
|
|
357
|
+
document in canonical form, with keys sorted at every level. Do not rely
|
|
358
|
+
on key order.
|
|
359
|
+
- An `Integer` outside the signed 64-bit range, a `NaN`/`Infinity` Float, a
|
|
360
|
+
non-finite `BigDecimal` or one whose digits exceed a signed 128-bit
|
|
361
|
+
mantissa, and any other type (`Date`, `Rational`, …) raise `QueryError` —
|
|
362
|
+
convert them yourself.
|
|
363
|
+
|
|
364
|
+
Details: [docs/types.md](https://github.com/porcupin26/skaidb-ruby/blob/main/docs/types.md).
|
|
365
|
+
|
|
366
|
+
## Errors
|
|
367
|
+
|
|
368
|
+
```
|
|
369
|
+
StandardError
|
|
370
|
+
└── Skaidb::Error # also: invalid consistency, pool closed
|
|
371
|
+
├── Skaidb::ConnectionError # dial/auth/transport failures, framing, closed connection
|
|
372
|
+
└── Skaidb::QueryError # the server rejected the statement; bad parameters
|
|
373
|
+
```
|
|
374
|
+
|
|
375
|
+
- A server `Error` frame is a **statement** error (`QueryError`); the
|
|
376
|
+
connection stays usable.
|
|
377
|
+
- A transport failure (`ConnectionError`) marks the connection broken; the
|
|
378
|
+
next statement re-dials, or a pool replaces it.
|
|
379
|
+
- `ArgumentError` for `Pool.new(maxsize: 0)` and for `Skaidb::Uuid.new` of a
|
|
380
|
+
string that is not a UUID.
|
|
381
|
+
|
|
382
|
+
## Transactions
|
|
383
|
+
|
|
384
|
+
skaidb autocommits every statement. There is no `BEGIN` on this driver's API;
|
|
385
|
+
where your server supports statement-level transaction control, issue
|
|
386
|
+
`BEGIN`/`COMMIT`/`ROLLBACK` as ordinary statements. **On a cluster,
|
|
387
|
+
transaction control is not available** — a `BEGIN` there is refused by the
|
|
388
|
+
server as a statement error. See the server documentation for what your
|
|
389
|
+
deployment supports.
|
|
390
|
+
|
|
391
|
+
## Thread safety
|
|
392
|
+
|
|
393
|
+
Threads may share the module but should not share a connection. A connection
|
|
394
|
+
serializes its own round-trips with a Mutex, and a stream claims it outright,
|
|
395
|
+
but the intended shape for concurrency is one connection per thread, which is
|
|
396
|
+
what a [pool](#connection-pool) gives you.
|
|
397
|
+
|
|
398
|
+
## Client identification and version
|
|
399
|
+
|
|
400
|
+
`Skaidb::VERSION` is the package version — defined once in `lib/skaidb.rb`,
|
|
401
|
+
read by the gemspec, and what the driver reports to the server in the `Hello`
|
|
402
|
+
frame after every handshake. It shows up as `client_name = 'ruby'` /
|
|
403
|
+
`client_version` in the server's `drivers` table:
|
|
404
|
+
|
|
405
|
+
```sql
|
|
406
|
+
SELECT client_name, client_version FROM drivers;
|
|
407
|
+
-- ruby | 1.0.3
|
|
408
|
+
```
|
|
409
|
+
|
|
410
|
+
The server records the row asynchronously, so a `SELECT` immediately after
|
|
411
|
+
connecting may not show it yet. `Hello` is telemetry: a server that does not
|
|
412
|
+
know the opcode answers with an error the driver ignores.
|
|
413
|
+
|
|
414
|
+
## Compatibility
|
|
415
|
+
|
|
416
|
+
- Ruby 2.7 – 3.4 (CI covers 3.1 – 3.4), MRI. No third-party dependencies;
|
|
417
|
+
`bigdecimal` is declared because it is a bundled gem since Ruby 3.4.
|
|
418
|
+
- Works with any skaidb server. Where a server predates an opcode the driver
|
|
419
|
+
falls back: prepared statements → client-side text binding, `Hello` →
|
|
420
|
+
ignored. `stream` and `exec_batch` need a server with those opcodes and
|
|
421
|
+
raise `QueryError` otherwise.
|
|
422
|
+
- The wire protocol (framing, SCRAM handshake, value encoding, opcodes) is
|
|
423
|
+
specified at <https://skaidb.org/docs/PROTOCOL.html>. The driver speaks the
|
|
424
|
+
binary protocol on port 7000; the server's REST/JSON gateway (port 7080)
|
|
425
|
+
is a dependency-free alternative for other clients.
|
|
426
|
+
|
|
427
|
+
## Examples
|
|
428
|
+
|
|
429
|
+
[`examples/`](https://github.com/porcupin26/skaidb-ruby/tree/main/examples)
|
|
430
|
+
contains runnable scripts: `basic.rb`, `prepared_batch.rb`, `stream.rb`,
|
|
431
|
+
`pool.rb`, `tls.rb`, `subscribe.rb`. Each takes
|
|
432
|
+
`host port user password` on the command line and defaults to
|
|
433
|
+
`localhost:7000`; the arguments after those differ per script:
|
|
434
|
+
|
|
435
|
+
- `basic.rb`, `prepared_batch.rb`, `stream.rb`, `pool.rb`: `[database]`
|
|
436
|
+
- `subscribe.rb`: `[database] [stream]` (the stream defaults to
|
|
437
|
+
`orders_stream`)
|
|
438
|
+
- `tls.rb`: `[ca.crt]`, a CA file to trust; without it the script uses the
|
|
439
|
+
system trust store, or verifies nothing when `SKAIDB_TLS_INSECURE` is set
|
|
440
|
+
(development only). It takes no database argument.
|
|
441
|
+
|
|
442
|
+
```sh
|
|
443
|
+
ruby examples/basic.rb localhost 7000 skaidb secret app
|
|
444
|
+
ruby examples/subscribe.rb localhost 7000 skaidb secret app orders_stream
|
|
445
|
+
ruby examples/tls.rb localhost 7000 skaidb secret /etc/skaidb/ca.crt
|
|
446
|
+
```
|
|
447
|
+
|
|
448
|
+
## Development
|
|
449
|
+
|
|
450
|
+
```sh
|
|
451
|
+
git clone https://github.com/porcupin26/skaidb-ruby
|
|
452
|
+
cd skaidb-ruby
|
|
453
|
+
rake test # unit tests against an in-process fake server; no skaidb needed
|
|
454
|
+
gem build skaidb.gemspec
|
|
455
|
+
```
|
|
456
|
+
|
|
457
|
+
The end-to-end test in `test/live/` runs against a real server when
|
|
458
|
+
`SKAIDB_LIVE=1` is set (with `SKAIDB_HOST`, `SKAIDB_PORT`, `SKAIDB_USER`,
|
|
459
|
+
`SKAIDB_PASSWORD`, `SKAIDB_DATABASE`), and is skipped otherwise:
|
|
460
|
+
|
|
461
|
+
```sh
|
|
462
|
+
SKAIDB_LIVE=1 SKAIDB_HOST=127.0.0.1 SKAIDB_PORT=7000 SKAIDB_USER=admin SKAIDB_PASSWORD=secret \
|
|
463
|
+
SKAIDB_DATABASE=default rake test:live
|
|
464
|
+
```
|
|
465
|
+
|
|
466
|
+
### Releasing
|
|
467
|
+
|
|
468
|
+
A release is a tag. Bump `Skaidb::VERSION` in `lib/skaidb.rb`, add the
|
|
469
|
+
`## [x.y.z]` entry to `CHANGELOG.md`, commit, then tag and push:
|
|
470
|
+
|
|
471
|
+
```sh
|
|
472
|
+
git tag v1.2.3 && git push origin main v1.2.3
|
|
473
|
+
```
|
|
474
|
+
|
|
475
|
+
[`publish.yml`](https://github.com/porcupin26/skaidb-ruby/blob/main/.github/workflows/publish.yml)
|
|
476
|
+
then runs the tests, checks the tag against `Skaidb::VERSION`, builds the
|
|
477
|
+
gem, pushes it to [RubyGems.org](https://rubygems.org/gems/skaidb) with the
|
|
478
|
+
`RUBYGEMS_API_KEY` repository secret (a rubygems.org API key with the push
|
|
479
|
+
scope; without the secret the push is skipped with a notice) and creates the
|
|
480
|
+
GitHub Release with the `.gem` attached.
|
|
481
|
+
|
|
482
|
+
## License
|
|
483
|
+
|
|
484
|
+
[SSPL-1.0](https://github.com/porcupin26/skaidb-ruby/blob/main/LICENSE) (Server Side Public License), the same license as skaidb.
|
data/docs/api.md
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# API reference
|
|
2
|
+
|
|
3
|
+
Everything lives under the `Skaidb` module in `lib/skaidb.rb`.
|
|
4
|
+
|
|
5
|
+
## `Skaidb.connect(**options) → Connection`
|
|
6
|
+
|
|
7
|
+
| Keyword | Default | Meaning |
|
|
8
|
+
|---|---|---|
|
|
9
|
+
| `host` | `"localhost"` | Node to dial (ignored when `seeds` is given). |
|
|
10
|
+
| `port` | `7000` | Binary-protocol port; also the port for a seed without one. |
|
|
11
|
+
| `user` | `"anonymous"` | SCRAM user name. |
|
|
12
|
+
| `password` | `""` | SCRAM password. Empty skips server-signature verification (anonymous connect). |
|
|
13
|
+
| `consistency` | `:quorum` | Default level for every statement: `:one`, `:quorum`, `:all`, `"ONE"`…, or `0`/`1`/`2`. |
|
|
14
|
+
| `timeout` | `10.0` | TCP connect timeout in seconds; `nil` for none. Reads are not bounded. |
|
|
15
|
+
| `database` | `nil` | Runs `USE "<database>"` after the handshake (and after every re-dial). |
|
|
16
|
+
| `seeds` | `nil` | `["host", "host:port", …]`, tried in random order until one connects **and** authenticates. |
|
|
17
|
+
| `tls` | `false` | Enable TLS with the system trust store. |
|
|
18
|
+
| `tls_ca` | `nil` | PEM file to trust instead of the system store; implies `tls`. |
|
|
19
|
+
| `tls_insecure` | `false` | TLS without any certificate verification; implies `tls`. Development only. |
|
|
20
|
+
| `tls_server_name` | `"skaidb"` | SNI name, also checked against the certificate's SANs. |
|
|
21
|
+
|
|
22
|
+
With a block, yields the connection and closes it when the block returns
|
|
23
|
+
(the block's value is returned). Raises `Skaidb::ConnectionError` when no
|
|
24
|
+
endpoint accepts the handshake (`no reachable endpoint in …: <last error>`),
|
|
25
|
+
`authentication denied: …` on a wrong password, and
|
|
26
|
+
`server signature mismatch` when the server fails mutual authentication.
|
|
27
|
+
|
|
28
|
+
## `Skaidb::Connection`
|
|
29
|
+
|
|
30
|
+
### Statements
|
|
31
|
+
|
|
32
|
+
- `exec(sql) → Result` (alias `query`) — run a statement with no parameters
|
|
33
|
+
at the connection's consistency.
|
|
34
|
+
- `exec_params(sql, params = [], consistency: nil) → Result` — bind `$1`,
|
|
35
|
+
`$2`, … from `params` (an Array), preparing the statement on the server and
|
|
36
|
+
sending the values typed. Falls back to client-side text binding when the
|
|
37
|
+
server refuses to prepare the statement (DDL, `USE`, old servers); on that
|
|
38
|
+
path an Array/Hash parameter raises `QueryError` that includes the server's
|
|
39
|
+
refusal reason. `consistency:` overrides the connection default for this
|
|
40
|
+
statement.
|
|
41
|
+
- `exec_batch(sql, rows, consistency: nil) → Integer` — prepare once, execute
|
|
42
|
+
once per row in one frame, return the total affected count. `rows` is an
|
|
43
|
+
Array of parameter Arrays. An empty `rows` returns `0` with no round-trip.
|
|
44
|
+
Raises `QueryError` if the statement cannot be prepared, if a row's length
|
|
45
|
+
differs from the statement's parameter count, or with the server's message
|
|
46
|
+
(which names the failing row index; earlier rows stay applied).
|
|
47
|
+
- `exec_prepared(id, params, consistency) → Result` — run a statement by the
|
|
48
|
+
id `prepare_server` returned, with an Array of typed values in `?` order.
|
|
49
|
+
- `prepare_server(sql) → [id, nparams] | nil` — prepare a statement with `?`
|
|
50
|
+
placeholders on the server; `nil` when the server refuses. Cached per
|
|
51
|
+
connection (240 entries); the cache is cleared on re-dial.
|
|
52
|
+
- `stream(sql, consistency: nil) { |row| … } → nil` — see
|
|
53
|
+
[streaming.md](streaming.md). Without a block returns an Enumerator.
|
|
54
|
+
- `subscribe(stream_name, after: nil, poll: 0.5) { |event| … }` — yields a
|
|
55
|
+
`CREATE STREAM` log's events forever, each a Hash with `"id"`, `"op"`,
|
|
56
|
+
`"k"`, `"ts"`, `"doc"`. Polls `_stream_<name>` 500 rows at a time with a
|
|
57
|
+
keyset cursor, sleeping `poll` seconds when caught up. `after:` is the last
|
|
58
|
+
`"id"` (an opaque String) already handled.
|
|
59
|
+
|
|
60
|
+
### State
|
|
61
|
+
|
|
62
|
+
- `consistency` / `consistency=` — the default level (`0`/`1`/`2`; the setter
|
|
63
|
+
accepts anything `Consistency.resolve` does).
|
|
64
|
+
- `usable?` — `false` once closed, once a transport error broke the socket,
|
|
65
|
+
or while a stream is in flight (including one abandoned through external
|
|
66
|
+
iteration). No round-trip.
|
|
67
|
+
- `closed`, `finished?` — `true` after `close`.
|
|
68
|
+
- `close` — closes the socket; idempotent. Does not take the connection's
|
|
69
|
+
lock, so another thread can use it to break a blocked read.
|
|
70
|
+
|
|
71
|
+
### Reconnect
|
|
72
|
+
|
|
73
|
+
A transport failure raises `Skaidb::ConnectionError` and marks the
|
|
74
|
+
connection broken. The next `exec`/`exec_params`/`exec_batch`/`stream`
|
|
75
|
+
re-dials across the original seeds, re-authenticates, re-sends `Hello`,
|
|
76
|
+
re-runs `USE`, and starts with an empty prepared-statement cache. If the
|
|
77
|
+
re-dial fails, that statement raises `ConnectionError` and the one after it
|
|
78
|
+
tries again.
|
|
79
|
+
|
|
80
|
+
## `Skaidb::Result`
|
|
81
|
+
|
|
82
|
+
Shaped like `PG::Result`; `Enumerable` over row Hashes.
|
|
83
|
+
|
|
84
|
+
| Member | Meaning |
|
|
85
|
+
|---|---|
|
|
86
|
+
| `fields` (alias `columns`) | Column names, in order. |
|
|
87
|
+
| `nfields` / `num_fields` | Column count. |
|
|
88
|
+
| `rows` | Rows as Arrays of decoded values. |
|
|
89
|
+
| `ntuples` / `num_tuples` | Row count. |
|
|
90
|
+
| `each { |hash| }` / `to_a` / `values` | Rows as Hashes with String keys. |
|
|
91
|
+
| `[i]` | Row `i` as a Hash, or `nil`. |
|
|
92
|
+
| `getvalue(row, col)` | One cell; `col` is an index or a column name. |
|
|
93
|
+
| `cmd_tuples` | Rows affected by `INSERT`/`UPDATE`/`DELETE`; `0` for `SELECT` and DDL. |
|
|
94
|
+
| `result_sets` | For a `CALL` whose body `EMIT`s, every result set in order (each a `Result`); the last one is this result's own `rows`/`fields`. `[]` otherwise. |
|
|
95
|
+
|
|
96
|
+
## `Skaidb::Pool`
|
|
97
|
+
|
|
98
|
+
`Skaidb::Pool.new(maxsize: 10, **connect_options)` — see
|
|
99
|
+
[pooling.md](pooling.md). `with { |conn| }`, `checkout`, `checkin(conn)`,
|
|
100
|
+
`close`. Raises `ArgumentError` for `maxsize < 1` and `Skaidb::Error` on
|
|
101
|
+
checkout from a closed pool.
|
|
102
|
+
|
|
103
|
+
## `Skaidb::Uuid`
|
|
104
|
+
|
|
105
|
+
A value wrapper for binding a UUID with the Uuid type tag (results decode to
|
|
106
|
+
a canonical String). `Uuid.new(str)` accepts 32 hex digits with or without
|
|
107
|
+
dashes and raises `ArgumentError` otherwise; `Uuid.random` (version 4);
|
|
108
|
+
`Uuid.from_bytes(raw16)`; `to_s` (canonical lowercase), `bytes` (16 raw
|
|
109
|
+
bytes), `==`/`eql?`/`hash` (equal to another `Uuid` or to a String with the
|
|
110
|
+
same canonical form).
|
|
111
|
+
|
|
112
|
+
## `Skaidb::Consistency`
|
|
113
|
+
|
|
114
|
+
`ONE = 0`, `QUORUM = 1`, `ALL = 2`; `resolve(value)` maps a Symbol, a String
|
|
115
|
+
(any case) or an Integer to the wire value and raises `Skaidb::Error`
|
|
116
|
+
otherwise.
|
|
117
|
+
|
|
118
|
+
## Errors
|
|
119
|
+
|
|
120
|
+
- `Skaidb::Error < StandardError` — base class; raised directly for an
|
|
121
|
+
invalid consistency level and for checkout from a closed pool.
|
|
122
|
+
- `Skaidb::ConnectionError < Error` — dial, handshake, framing and transport
|
|
123
|
+
failures; a statement on a closed connection. The connection is broken and
|
|
124
|
+
will re-dial on the next statement.
|
|
125
|
+
- `Skaidb::QueryError < Error` — the server rejected the statement (the
|
|
126
|
+
message is the server's), or a parameter could not be bound
|
|
127
|
+
(placeholder/parameter mismatch, out-of-range Integer, non-finite Float or
|
|
128
|
+
BigDecimal, unsupported type). The connection stays usable.
|
|
129
|
+
|
|
130
|
+
## Codec helpers
|
|
131
|
+
|
|
132
|
+
Exposed for tests and tooling: `Skaidb.encode_value(v)` / `Skaidb.decode_value(reader)`
|
|
133
|
+
(the §4 value codec), `Skaidb::Reader`, `Skaidb.quote(v)` and
|
|
134
|
+
`Skaidb.bind(sql, params)` (the client-side text fallback),
|
|
135
|
+
`Skaidb.to_qmark(sql, params)` (`$N` → `?` rewrite with the values in wire
|
|
136
|
+
order), `Skaidb.decimal_parts(bigdecimal)`, `Skaidb.time_ms(time)`,
|
|
137
|
+
`Skaidb.format_uuid(bytes)`, `Skaidb.scram(password, salt, iterations, auth_message)`.
|
|
138
|
+
|
|
139
|
+
## `Skaidb::VERSION`
|
|
140
|
+
|
|
141
|
+
The package version, defined once in `lib/skaidb.rb`. The gemspec reads it
|
|
142
|
+
and the driver sends it in the `Hello` frame after every handshake; the
|
|
143
|
+
server shows it in the `drivers` table (`client_name = 'ruby'`,
|
|
144
|
+
`client_version = Skaidb::VERSION`). The server records that row
|
|
145
|
+
asynchronously, so a query issued immediately after connecting may not show
|
|
146
|
+
it yet.
|